Baustein 8: Lernzielkontrollen mit Freigabe, Abgabe und Korrektur

- Ausbilder gibt frei; vorher sieht der Azubi die Fragen nicht
- Freigabe laesst sich zuruecknehmen, solange nicht abgegeben wurde
- Antworten werden beim Tippen je Frage zwischengespeichert
- Musterloesungen erst nach dem Absenden: sichtbareKontrolle() entfernt sie
  serverseitig, bevor die Daten in die Ausgabe gelangen
- Korrektur mit Punkten und Anmerkung je Frage, Summe und Note laufen mit
- Notenschluessel der IHK; solange eine Frage offen ist, bleibt das Ergebnis
  vorlaeufig und die Kontrolle gilt nicht als korrigiert
- Ergebnis bei beiden sichtbar, Schnitt ueber alle Kontrollen
- Abgegebene Kontrollen unter "Was auf dich wartet" und im Wochenfortschritt
- scripts/pruefungen/musterloesung-dichtheit.mjs prueft die Sperre gegen den
  echten Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext

36 neue Tests, davon 21 fuer die Sichtbarkeit der Musterloesung.
Insgesamt 277.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 08:57:00 +02:00
co-authored by Claude Opus 5
parent 7c4a051347
commit d226a6bae8
42 changed files with 2132 additions and 9 deletions
+38
View File
@@ -26,6 +26,43 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [0.8.0] – 2026-09-11
Lernzielkontrollen – und die strengste Zusage des ganzen Auftrags eingelöst.
### Hinzugefügt
- **Freigabe durch den Ausbilder**: Vorher sieht der Azubi die Fragen gar
nicht. Eine Freigabe lässt sich zurücknehmen, solange nicht abgegeben wurde
- **Fragebogen** mit offenen Fragen und Punktzahl je Frage; die Antworten
werden beim Tippen zwischengespeichert, mit Zeitstempel in der Kopfzeile.
Eine Kontrolle dauert 45 Minuten – ein verlorener Text wäre nicht
wiedergutzumachen
- **Musterlösungen erst nach dem Absenden.** Die Sperre sitzt serverseitig in
`sichtbareKontrolle()`: Die Lösung wird aus den Daten entfernt, **bevor** sie
in die Ausgabe gelangt – nicht erst in der Oberfläche versteckt. Wer den
Seitenquelltext liest oder die Serverantwort mitschneidet, findet nichts
- **Korrektur**: Antwort des Azubis und Musterlösung nebeneinander, Punktefeld
und Anmerkung je Frage, Anmerkung zur ganzen Kontrolle. Summe, Prozentsatz
und Note laufen beim Verteilen der Punkte mit
- **Notenschlüssel der IHK** (92/81/67/50/30 Prozent). Solange eine Frage ohne
Punkte ist, gilt das Ergebnis als vorläufig und die Kontrolle nicht als
korrigiert
- Ergebnis erscheint bei beiden; die Übersicht des Azubis zeigt zusätzlich den
Schnitt über alle korrigierten Kontrollen
- Abgegebene, noch nicht korrigierte Kontrollen erscheinen beim Ausbilder unter
„Was auf dich wartet"
- Abgegebene Kontrollen zählen im Wochenfortschritt mit
- 36 Tests, davon 21 allein für die Sichtbarkeit der Musterlösung – für jeden
Betrachter und jeden Zustand einzeln
### Geprüft
Der Wortlaut der Musterlösungen wurde im rohen Seitenquelltext gesucht, in
jedem Zustand. Vor der Abgabe: kein einziger Treffer. Nach der Abgabe: alle
fünf. Der Prüfaufbau weist sich damit selbst als tauglich aus – er findet die
Lösungen, wenn sie da sein dürfen.
## [0.7.0] – 2026-09-10
Das Berichtsheft – der Teil, ohne den es keine Zulassung zur Abschlussprüfung
@@ -319,6 +356,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.8.0]: #080--2026-09-11
[0.7.0]: #070--2026-09-10
[0.6.0]: #060--2026-09-10
[0.5.0]: #050--2026-09-10
+39
View File
@@ -7,6 +7,45 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-11 · Baustein 8 – Lernzielkontrollen
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 06:25 | 06:35 | 0:10 | Notenschlüssel und Auswertung als reine Rechnung, 15 Tests |
| 06:35 | 06:45 | 0:10 | Sichtbarkeitsschicht für die Musterlösung, 21 Tests |
| 06:45 | 06:55 | 0:10 | Datenmodell, Migration, Repository, Serveraktionen |
| 06:55 | 07:15 | 0:20 | Fragebogen mit Zwischenspeichern, Korrekturansicht, Übersichten |
| 07:15 | 08:50 | 1:35 | Bau und Durchstich – davon über eine Stunde Wartezeit (siehe unten) |
**Summe Baustein 8: rund 2 h 25 min, davon knapp 1 h reine Wartezeit**
### Was aufgefallen ist
- Der Kern dieses Bausteins ist eine einzige Funktion: `sichtbareKontrolle()`
entfernt die Musterlösung, bevor die Daten in die Ausgabe gelangen. Sie nimmt
bewusst den ganzen Betrachter und den Bearbeitungsstand entgegen statt eines
vorberechneten Wahrheitswerts – so kann kein Aufrufer versehentlich `true`
übergeben.
- Beim Lecktest zunächst ein Fehlalarm: Die Prüfung meldete
„Discover, Offer, Request, Acknowledge" im Quelltext. Das war meine **eigene
Testantwort**, die ich zuvor eingetippt hatte. Danach ein sauberer Aufbau,
der die Musterlösungen direkt aus der Inhaltsdatei liest und gegen Text
prüft, den ich nicht selbst eingebe. Ergebnis: vor der Abgabe null Treffer,
nach der Abgabe alle fünf – damit ist auch belegt, dass die Suche überhaupt
finden kann.
- **Eine Stunde an der Umgebung verloren.** Erst hing `next build` endlos, weil
der laufende Server `.next` festhielt und ich ihn mitten im Schreiben
abgeschossen hatte. Nach `Remove-Item .next -Recurse` lief der Bau in acht
Sekunden. Danach brauchte `next start` **2822 Sekunden** bis „Ready" – das
Dateisystem war zu dem Zeitpunkt extrem langsam, vermutlich ein Virenscanner
über dem frisch gebauten Verzeichnis. Lehre: Vor einem Bau immer erst den
Server stoppen, und ein hängender Start ist nicht zwingend ein Codefehler.
- Die laufende Summe beim Korrigieren war nachträglich die beste Idee an der
Oberfläche: Man sieht beim Verteilen der Punkte, auf welcher Note man landet,
statt es hinterher zu erfahren.
---
## 2026-09-10 · Baustein 7 – Berichtsheft
| Von | Bis | Dauer | Tätigkeit |
+43
View File
@@ -309,3 +309,46 @@ Leerzeichen keine Wortgrenze. Genau dieser Fall war zuerst durchgerutscht.
Tätigkeitsverben und die Gegenüberstellungen sind Bedienhilfe der Anwendung,
kein Ausbildungsinhalt. Sie ändern sich nicht mit dem Lehrgang und gehören
deshalb nicht in dieselbe Ablage wie Handouts und Aufgaben.
## E32 · Die Musterlösung wird entfernt, nicht versteckt
Der Auftrag verlangt: Musterlösungen dürfen dem Azubi erst nach dem Absenden
angezeigt werden und **niemals vorher über die Schnittstelle abrufbar** sein.
Umgesetzt ist das in `src/lib/kontrollen/sicht.ts`. `sichtbareKontrolle()`
baut die Fassung, die den Browser erreichen darf; ist die Lösung nicht
freigegeben, steht dort `null`. Es gibt keine Stelle, an der die vollständige
Kontrolle an die Oberfläche gereicht und dort ausgeblendet wird.
Zwei Feinheiten:
- Die Funktion nimmt den **ganzen Betrachter** und den Bearbeitungsstand
entgegen, nicht ein vorberechnetes `darfSehen`. Ein Aufrufer kann also nicht
aus Versehen `true` übergeben.
- Die Zwischenspeicher-Aktion gibt nur einen Zeitstempel zurück. Sie reicht
bewusst keinen Datensatz durch, in dem die Lösung mitreisen könnte.
Geprüft wird das doppelt: 21 Tests über alle Betrachter und Zustände, und ein
Durchstich, der den Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext
sucht.
## E33 · Notenschlüssel der IHK, und ein vorläufiges Ergebnis bleibt vorläufig
Gerechnet wird mit dem 100-Punkte-Schlüssel der IHK (ab 92 / 81 / 67 / 50 / 30
Prozent). Wer sich daran gewöhnt, erlebt in der Abschlussprüfung keine
Überraschung.
Solange eine Frage ohne Punkte ist, gilt die Kontrolle **nicht** als
korrigiert: `korrigiert_am` bleibt leer, das Ergebnis heißt „vorläufig", und
die Kontrolle bleibt in der Liste „Was auf dich wartet". Ein halb korrigierter
Fragebogen, der als fertig erscheint, wäre schlimmer als gar keine Anzeige.
## E34 · Antworten werden beim Tippen gespeichert, je Frage einzeln
Eine Lernzielkontrolle dauert 45 Minuten. Ein Browserabsturz oder ein
versehentlich geschlossener Reiter darf nicht 45 Minuten kosten.
Gespeichert wird je Frage einzeln mit knapp einer Sekunde Verzögerung – nicht
das ganze Formular auf einmal. So bleibt jeder Schreibvorgang klein, und eine
lange Antwort auf Frage 1 wird nicht bei jedem Tastendruck in Frage 6 neu
geschrieben.
+3 -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 7 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten und das Berichtsheft.
> Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> **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
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
---
+9 -1
View File
@@ -84,7 +84,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.5 – Bewertung
- [ ] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [x] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [x] Ausbilder gibt frei, kann die Freigabe vor der Abgabe zurücknehmen
- [x] Fragen erst ab der Freigabe sichtbar
- [x] Antworten werden beim Tippen zwischengespeichert
- [x] Musterlösungen erst nach dem Absenden – serverseitig entfernt,
nicht in der Oberfläche versteckt
- [x] Korrektur mit Punkten je Frage, Anmerkung je Frage und gesamt
- [x] Notenschlüssel der IHK, Zwischenstand beim Korrigieren sichtbar
- [x] Ergebnis bei beiden sichtbar, Verlauf über alle Kontrollen
- [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
## 1.0 – Abrundung
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1,104 @@
import { chromium } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
/**
* Gezielte Prüfung der einen Zusage, auf die es bei den Lernzielkontrollen
* ankommt: Die Musterlösung darf vor dem Absenden nirgends herauskommen.
*
* Geprüft wird gegen den **echten** Wortlaut aus der Inhaltsdatei, nicht gegen
* ausgedachte Zeichenketten – und im rohen Quelltext samt RSC-Nutzlast, nicht
* im sichtbaren Text.
*/
const BASIS = 'http://localhost:3311';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
// Musterlösungen direkt aus der Datei lesen – so kann der Test nicht veralten.
const yaml = fs.readFileSync(path.join(process.cwd(), 'content/lernzielkontrollen/woche-01.yaml'), 'utf8');
const loesungen = [...yaml.matchAll(/musterloesung:\s*>-\s*\n((?:\s{6}.+\n)+)/g)].map((t) =>
t[1].replace(/\s+/g, ' ').trim(),
);
// Aussagekräftige Bruchstücke: lang genug, um nicht zufällig vorzukommen.
const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.length > 40);
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
const browser = await chromium.launch();
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
async function anmelden(k) {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', k.email);
await seite.fill('#passwort', k.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
async function abmelden() {
await seite.click('button:has-text("Abmelden")');
await seite.waitForURL(/anmelden/);
}
/** Sucht im rohen HTML **und** in der RSC-Nutzlast. */
async function lecksuche(beschriftung) {
const html = await seite.content();
const rsc = await seite.evaluate(() =>
// Next legt die Server-Nutzlast in self.__next_f ab – dort steht alles,
// was der Server an den Browser geschickt hat.
(globalThis.self?.__next_f ?? []).map((t) => (Array.isArray(t) ? String(t[1] ?? '') : '')).join(''),
);
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
const nullFelder = (rsc.match(/musterloesung.{0,12}null/g) ?? []).length;
console.log(
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
` | RSC-Größe ${rsc.length} | "musterloesung":null ${nullFelder}x`,
);
return treffer;
}
console.log('\n1) Vor der Freigabe');
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('h1');
await lecksuche('gesperrt');
console.log('\n2) Ausbilder gibt frei');
await abmelden();
await anmelden(AUSBILDER);
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('.kontrollzeile');
await seite.locator('.kontrollzeile', { hasText: 'Woche 1' }).locator('button:has-text("Freigeben")').click();
await seite.waitForTimeout(1500);
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
await abmelden();
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('.frage-text');
console.log(` Fragen sichtbar: ${(await seite.$$('.frage-text')).length}`);
const leck = await lecksuche('freigegeben, nicht abgegeben');
console.log('\n4) Nach dem Zwischenspeichern einer Antwort');
await seite.locator('textarea[id^=antwort-]').first().fill('Person, Gerät, Fehlerbild, Zeitpunkt.');
await seite.waitForTimeout(1600);
await seite.reload();
await seite.waitForSelector('.frage-text');
const leck2 = await lecksuche('nach Zwischenspeichern');
console.log('\n5) Nach der Abgabe muss sie da sein');
await seite.click('button:has-text("Kontrolle abgeben")');
await seite.waitForTimeout(2500);
await seite.reload();
await seite.waitForSelector('h1');
const nachher = await lecksuche('abgegeben');
console.log(` Musterlösungen jetzt sichtbar: ${nachher.length} von ${bruchstuecke.length}`);
await browser.close();
const gescheitert = leck.length + leck2.length;
console.log(`\n${gescheitert === 0 ? 'BESTANDEN' : 'DURCHGEFALLEN'}: vor der Abgabe ${gescheitert} Leck(s).`);
process.exit(gescheitert === 0 && nachher.length === bruchstuecke.length ? 0 : 1);
+8
View File
@@ -126,6 +126,14 @@ async function main() {
await seite.waitForSelector('h1');
await bild(seite, '21-berichtsheft');
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
await bild(seite, '23-kontrollen');
await seite.goto(`${BASIS}/kontrollen/woche-03`);
await seite.waitForSelector('h1');
await bild(seite, '24-kontrolle-ergebnis');
const blatt = await seite.$('.blattzeile-link');
if (blatt) {
await blatt.click();
@@ -0,0 +1,140 @@
'use client';
import { useActionState, useEffect, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { antwortZwischenspeichern, kontrolleAbgeben, type Rueckmeldung } from './aktionen';
import type { SichtbareFrage } from '@/lib/kontrollen/sicht';
/**
* Der Fragebogen.
*
* Antworten werden beim Tippen zwischengespeichert – eine Kontrolle dauert
* 45 Minuten, und ein verlorener Text nach einem versehentlichen Schließen
* wäre nicht wiedergutzumachen.
*
* Zur Musterlösung: Sie steckt gar nicht in den Daten, die diese Komponente
* bekommt, solange nicht abgegeben wurde. Das entscheidet der Server.
*/
export function Fragebogen({
kontrolleId,
fragen,
anfangsantworten,
gesamtpunkte,
dauerMinuten,
}: {
kontrolleId: string;
fragen: SichtbareFrage[];
anfangsantworten: Record<string, string>;
gesamtpunkte: number;
dauerMinuten: number | null;
}) {
const [antworten, setzeAntworten] = useState<Record<string, string>>(anfangsantworten);
const [gespeichert, setzeGespeichert] = useState<string | null>(null);
const [speicherfehler, setzeSpeicherfehler] = useState<string | null>(null);
const [zustand, absenden] = useActionState<Rueckmeldung, FormData>(kontrolleAbgeben, {});
const zeitgeber = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => {
const laufende = zeitgeber.current;
return () => {
for (const gestellt of Object.values(laufende)) clearTimeout(gestellt);
};
}, []);
function aendern(frageId: string, wert: string) {
setzeAntworten((v) => ({ ...v, [frageId]: wert }));
clearTimeout(zeitgeber.current[frageId]);
zeitgeber.current[frageId] = setTimeout(async () => {
const ergebnis = await antwortZwischenspeichern(kontrolleId, frageId, wert);
if ('fehler' in ergebnis) {
setzeSpeicherfehler(ergebnis.fehler);
} else {
setzeSpeicherfehler(null);
setzeGespeichert(
new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium', timeZone: 'Europe/Berlin' }).format(
new Date(ergebnis.gespeichertUm),
),
);
}
}, 900);
}
const beantwortet = fragen.filter((f) => (antworten[f.id] ?? '').trim() !== '').length;
return (
<form action={absenden} className="stapel">
<input type="hidden" name="kontrolleId" value={kontrolleId} />
<div className="kontrolle-kopf karte">
<div>
<strong>
{beantwortet} von {fragen.length} Fragen beantwortet
</strong>
<span className="leise" style={{ display: 'block' }}>
{gesamtpunkte} Punkte insgesamt
{dauerMinuten ? ` · ${dauerMinuten} Minuten vorgesehen` : ''}
</span>
</div>
<span className="leise speicherstand" aria-live="polite">
{speicherfehler ? (
<span style={{ color: 'var(--fehler)' }}>{speicherfehler}</span>
) : gespeichert ? (
`Zwischengespeichert um ${gespeichert}`
) : (
'Wird beim Tippen zwischengespeichert'
)}
</span>
</div>
<ol className="fragenliste">
{fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<label htmlFor={`antwort-${frage.id}`} className="nur-lesehilfe">
Antwort auf Frage {stelle + 1}
</label>
<textarea
id={`antwort-${frage.id}`}
name={`antwort-${frage.id}`}
rows={5}
maxLength={20000}
value={antworten[frage.id] ?? ''}
onChange={(e) => aendern(frage.id, e.target.value)}
placeholder="Antworte in ganzen Sätzen – in der Abschlussprüfung wird ebenfalls nicht in Stichworten bewertet."
/>
</li>
))}
</ol>
<Meldung zustand={zustand} />
<section className="karte-grund stapel-eng">
<p style={{ margin: 0 }}>
<strong>Abgeben ist endgültig.</strong> Danach lässt sich nichts mehr ändern – dafür siehst du die
Musterlösungen.
</p>
{beantwortet < fragen.length ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
{fragen.length - beantwortet}{' '}
{fragen.length - beantwortet === 1 ? 'Frage ist' : 'Fragen sind'} noch unbeantwortet.
</p>
) : null}
<div>
<Absenden className="knopf knopf-akzent" wartetext="Wird abgegeben …">
Kontrolle abgeben
</Absenden>
</div>
</section>
</form>
);
}
@@ -0,0 +1,157 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { kontrolleKorrigieren, type Rueckmeldung } from './aktionen';
import { noteZu } from '@/lib/kontrollen/bewertung';
export type Korrekturfrage = {
id: string;
frage: string;
punkte: number;
musterloesung: string;
antwort: string;
vergeben: number | null;
anmerkung: string;
};
/**
* Korrekturansicht des Ausbilders.
*
* Je Frage: Fragetext, die Antwort des Azubis, die Musterlösung daneben, ein
* Punktefeld und ein Feld für eine Anmerkung. Die Zwischensumme und die daraus
* folgende Note laufen beim Tippen mit, damit man beim Verteilen der Punkte
* sieht, wo man landet.
*/
export function Korrektur({
kontrolleId,
azubiId,
fragen,
anmerkung,
}: {
kontrolleId: string;
azubiId: string;
fragen: Korrekturfrage[];
anmerkung: string;
}) {
const [punkte, setzePunkte] = useState<Record<string, string>>(() =>
Object.fromEntries(fragen.map((f) => [f.id, f.vergeben === null ? '' : String(f.vergeben)])),
);
const [zustand, korrigieren] = useActionState<Rueckmeldung, FormData>(kontrolleKorrigieren, {});
const moeglich = fragen.reduce((summe, f) => summe + f.punkte, 0);
const erreicht = fragen.reduce((summe, f) => {
const wert = Number((punkte[f.id] ?? '').replace(',', '.'));
return summe + (Number.isFinite(wert) && wert > 0 ? Math.min(wert, f.punkte) : 0);
}, 0);
const offen = fragen.filter((f) => (punkte[f.id] ?? '').trim() === '').length;
const prozent = moeglich > 0 ? Math.round((erreicht / moeglich) * 100) : 0;
const note = noteZu(prozent);
return (
<form action={korrigieren} className="stapel">
<input type="hidden" name="kontrolleId" value={kontrolleId} />
<input type="hidden" name="azubiId" value={azubiId} />
<div className="kontrolle-kopf karte" style={{ position: 'sticky', top: 0, zIndex: 5 }}>
<div>
<strong>
{runde(erreicht)} von {moeglich} Punkten · {prozent} %
</strong>
<span className="leise" style={{ display: 'block' }}>
{offen > 0
? `${offen} ${offen === 1 ? 'Frage' : 'Fragen'} noch ohne Punkte – Ergebnis vorläufig`
: `Note ${note.note} (${note.text})`}
</span>
</div>
<span className={`note-anzeige${offen > 0 ? ' note-vorlaeufig' : ''}`} aria-hidden="true">
{note.note}
</span>
</div>
<ol className="fragenliste">
{fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<div className="korrektur-spalten">
<div>
<span className="wochentag-marke">Antwort des Azubis</span>
{frage.antwort.trim() ? (
<p className="notiz-anzeige">{frage.antwort}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Nicht beantwortet.
</p>
)}
</div>
<div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</div>
</div>
<div className="korrektur-bewertung">
<div>
<label htmlFor={`punkte-${frage.id}`}>Punkte (von {frage.punkte})</label>
<input
id={`punkte-${frage.id}`}
name={`punkte-${frage.id}`}
type="number"
min={0}
max={frage.punkte}
step={0.5}
value={punkte[frage.id] ?? ''}
onChange={(e) => setzePunkte((v) => ({ ...v, [frage.id]: e.target.value }))}
/>
</div>
<div style={{ flex: 1 }}>
<label htmlFor={`anmerkung-${frage.id}`}>Anmerkung zu dieser Frage</label>
<input
id={`anmerkung-${frage.id}`}
name={`anmerkung-${frage.id}`}
type="text"
maxLength={500}
defaultValue={frage.anmerkung}
placeholder="z. B. Rechenweg fehlt"
/>
</div>
</div>
</li>
))}
</ol>
<section className="karte-grund stapel-eng">
<label htmlFor="gesamtanmerkung">Anmerkung zur ganzen Kontrolle</label>
<textarea
id="gesamtanmerkung"
name="anmerkung"
rows={3}
maxLength={4000}
defaultValue={anmerkung}
placeholder="Was lief gut, woran sollte er arbeiten?"
/>
<Meldung zustand={zustand} />
<div>
<Absenden className="knopf knopf-akzent">
{offen > 0 ? 'Zwischenstand speichern' : 'Korrektur abschließen'}
</Absenden>
</div>
</section>
</form>
);
}
function runde(wert: number): number {
return Math.round(wert * 100) / 100;
}
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { antwortenVon, ergebnisVon } from '@/lib/repos/kontrollen';
import { darfFragenSehen, sichtbareKontrolle, zustandVon } from '@/lib/kontrollen/sicht';
import { alsZeitpunktText } from '@/lib/zeit';
import { Fragebogen } from '../Fragebogen';
import { Korrektur, type Korrekturfrage } from '../Korrektur';
import { standZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ kontrolleId: string }> }): Promise<Metadata> {
const { kontrolleId } = await params;
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
return { title: inhalt?.titel ?? 'Lernzielkontrolle' };
}
export default async function KontrollSeite({
params,
searchParams,
}: {
params: Promise<{ kontrolleId: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { kontrolleId } = await params;
const { azubi: azubiParameter } = await searchParams;
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
const gelesen = await standZumLesen(azubiId, kontrolleId);
if (!gelesen) notFound();
const { bezug, stand } = gelesen;
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const bearbeitungsstand = {
azubiId,
freigegebenAm: stand.freigegebenAm,
abgegebenAm: stand.abgegebenAm,
};
// Vor der Freigabe bekommt der Azubi die Fragen gar nicht erst zu sehen.
if (!darfFragenSehen(benutzer, bearbeitungsstand, bezug)) {
return (
<div className="stapel">
<nav className="leise nicht-drucken">
<Link href="/kontrollen">Lernzielkontrollen</Link>
</nav>
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
🔒
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Noch nicht freigegeben</h1>
<p className="leise" style={{ marginBottom: 0 }}>
Dein Ausbilder gibt diese Kontrolle frei, wenn es soweit ist. Bis dahin sind die Fragen nicht
einsehbar.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/kontrollen" className="knopf">
Zurück zur Übersicht
</Link>
</div>
</div>
</div>
);
}
// Hier wird die Musterlösung entfernt, falls sie nicht gezeigt werden darf.
const sicht = sichtbareKontrolle(inhalt, benutzer, bearbeitungsstand, bezug);
const antworten = antwortenVon(stand.id);
const zustand = zustandVon({ ...bearbeitungsstand, korrigiertAm: stand.korrigiertAm });
const ergebnis = stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
const korrektor = stand.korrigiertVon ? benutzerNachId(stand.korrigiertVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/kontrollen">Lernzielkontrollen</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="kontroll-woche kontroll-woche-gross">Woche {inhalt.woche}</span>
{ergebnis && stand.korrigiertAm ? (
<span className="marke-status" data-ton="gut">
✓ {zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · {ergebnis.prozent} % · Note{' '}
{ergebnis.note}
</span>
) : null}
{zustand === 'abgegeben' ? (
<span className="marke-status" data-ton="warnung">
▲ abgegeben {alsZeitpunktText(stand.abgegebenAm)}
</span>
) : null}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{inhalt.titel}</h1>
{inhalt.hinweis ? <p className="handout-worum">{inhalt.hinweis}</p> : null}
{!istAzubi ? <p className="leise">{azubi.name}</p> : null}
</header>
{/* --- Ergebnis, sobald korrigiert --- */}
{ergebnis && stand.korrigiertAm ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="ergebnis" style={{ margin: 0, fontSize: '1rem' }}>
Ergebnis
</h2>
<span className="leise">
korrigiert {alsZeitpunktText(stand.korrigiertAm)}
{korrektor ? ` von ${korrektor.name}` : ''}
</span>
</div>
<div className="ergebnis-balken">
<div className="balken" style={{ height: '14px' }}>
<span style={{ width: `${ergebnis.prozent}%` }} />
</div>
<strong>
{zahl(ergebnis.erreicht)} / {zahl(ergebnis.moeglich)} · {ergebnis.prozent} % · Note {ergebnis.note}{' '}
({ergebnis.notentext})
</strong>
</div>
{stand.anmerkung ? <p className="notiz-anzeige">{stand.anmerkung}</p> : null}
</section>
) : null}
{/* --- Arbeitsansicht --- */}
{istAzubi && zustand === 'freigegeben' ? (
<Fragebogen
kontrolleId={inhalt.id}
fragen={sicht.fragen}
anfangsantworten={Object.fromEntries([...antworten].map(([id, a]) => [id, a.antwort]))}
gesamtpunkte={sicht.gesamtpunkte}
dauerMinuten={sicht.dauerMinuten}
/>
) : null}
{/* --- Nach der Abgabe: Antworten und Musterlösungen --- */}
{istAzubi && stand.abgegebenAm ? (
<ol className="fragenliste">
{sicht.fragen.map((frage, stelle) => {
const eigene = antworten.get(frage.id);
return (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{eigene?.punkte != null ? `${zahl(eigene.punkte)} / ` : ''}
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<div className="korrektur-spalten">
<div>
<span className="wochentag-marke">Deine Antwort</span>
{eigene?.antwort.trim() ? (
<p className="notiz-anzeige">{eigene.antwort}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Nicht beantwortet.
</p>
)}
</div>
<div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</div>
</div>
{eigene?.anmerkung ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
{eigene.anmerkung}
</p>
) : null}
</li>
);
})}
</ol>
) : null}
{/* --- Korrektur durch den Ausbilder --- */}
{!istAzubi && stand.abgegebenAm ? (
<Korrektur
kontrolleId={inhalt.id}
azubiId={azubiId}
anmerkung={stand.anmerkung ?? ''}
fragen={inhalt.fragen.map<Korrekturfrage>((frage) => ({
id: frage.id,
frage: frage.frage,
punkte: frage.punkte,
musterloesung: frage.musterloesung,
antwort: antworten.get(frage.id)?.antwort ?? '',
vergeben: antworten.get(frage.id)?.punkte ?? null,
anmerkung: antworten.get(frage.id)?.anmerkung ?? '',
}))}
/>
) : null}
{/* --- Ausbilder vor der Abgabe: nur durchsehen --- */}
{!istAzubi && !stand.abgegebenAm ? (
<>
<p className="hinweis hinweis-warnung">
{zustand === 'gesperrt'
? 'Diese Kontrolle ist für den Azubi noch nicht freigegeben – du siehst sie zum Durchsehen.'
: 'Freigegeben, aber noch nicht abgegeben. Die Antworten erscheinen hier, sobald der Azubi abgibt.'}
</p>
<ol className="fragenliste">
{sicht.fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</li>
))}
</ol>
</>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>content/lernzielkontrollen/{inhalt.id}.yaml</code>
</p>
</article>
);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+166
View File
@@ -0,0 +1,166 @@
'use server';
import { revalidatePath } from 'next/cache';
import { ausbildungVonAzubi, erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { inhalte } from '@/lib/inhalte';
import {
abgeben,
antwortSpeichern,
freigabeZuruecknehmen,
freigeben,
korrigieren,
sorgeFuerStand,
standVon,
} from '@/lib/repos/kontrollen';
import { darfBearbeiten } from '@/lib/kontrollen/sicht';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(kontrolleId: string) {
revalidatePath('/kontrollen');
revalidatePath(`/kontrollen/${kontrolleId}`);
revalidatePath('/');
}
/**
* Antwort zwischenspeichern.
*
* Wird beim Tippen mit kurzer Verzögerung aufgerufen. Gibt bewusst **nichts**
* zurück außer einem Zeitstempel – insbesondere keine Musterlösung, auch nicht
* versehentlich über einen mitgegebenen Datensatz.
*/
export async function antwortZwischenspeichern(
kontrolleId: string,
frageId: string,
antwort: string,
): Promise<{ gespeichertUm: string } | { fehler: string }> {
const benutzer = await erfordereBenutzer();
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
if (!inhalt.fragen.some((f) => f.id === frageId)) return { fehler: 'Diese Frage gibt es nicht.' };
if (antwort.length > 20000) return { fehler: 'Die Antwort ist zu lang.' };
const stand = standVon(benutzer.id, kontrolleId);
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
// Die eine Regel, auf die es ankommt: geschrieben wird nur zwischen
// Freigabe und Abgabe, und nur vom Azubi selbst.
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
return { fehler: 'Diese Kontrolle lässt sich gerade nicht bearbeiten.' };
}
antwortSpeichern(stand.id, frageId, antwort);
return { gespeichertUm: new Date().toISOString() };
}
export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
const kontrolleId = String(formular.get('kontrolleId') ?? '');
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
const stand = standVon(benutzer.id, kontrolleId);
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
return { fehler: 'Diese Kontrolle lässt sich gerade nicht abgeben.' };
}
// Letzten Stand der Antworten übernehmen, damit nichts verlorengeht.
for (const frage of inhalt.fragen) {
const antwort = formular.get(`antwort-${frage.id}`);
if (typeof antwort === 'string') antwortSpeichern(stand.id, frage.id, antwort);
}
abgeben(stand.id);
aktualisiere(kontrolleId);
return { erfolg: 'Abgegeben. Die Musterlösungen stehen jetzt unter den Fragen.' };
}
// --- Ausbilder -------------------------------------------------------------
export async function kontrolleFreigeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!inhalte().kontrolleNach.has(kontrolleId)) return;
freigeben(azubiId, kontrolleId, ausbilder.id);
aktualisiere(kontrolleId);
}
export async function freigabeZurueck(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const stand = standVon(azubiId, kontrolleId);
// Eine abgegebene Kontrolle wird nicht wieder zugemacht.
if (!stand || stand.abgegebenAm) return;
freigabeZuruecknehmen(stand.id);
aktualisiere(kontrolleId);
}
export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
const stand = standVon(azubiId, kontrolleId);
if (!stand) return { fehler: 'Zu dieser Kontrolle gibt es noch keinen Stand.' };
if (!stand.abgegebenAm) return { fehler: 'Die Kontrolle ist noch nicht abgegeben.' };
const bewertungen = inhalt.fragen.map((frage) => {
const roh = String(formular.get(`punkte-${frage.id}`) ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
const anmerkung = String(formular.get(`anmerkung-${frage.id}`) ?? '').trim();
return {
frageId: frage.id,
punkte: roh === '' || !Number.isFinite(gelesen) ? null : gelesen,
anmerkung: anmerkung || null,
};
});
const gesamtanmerkung = String(formular.get('anmerkung') ?? '').trim();
if (gesamtanmerkung.length > 4000) return { fehler: 'Die Anmerkung ist zu lang.' };
const ergebnis = korrigieren(stand.id, ausbilder.id, inhalt, bewertungen, gesamtanmerkung || null);
aktualisiere(kontrolleId);
return ergebnis.vollstaendig
? {
erfolg: `Korrigiert: ${zahl(ergebnis.erreicht)} von ${zahl(ergebnis.moeglich)} Punkten – ${ergebnis.prozent} %, Note ${ergebnis.note} (${ergebnis.notentext}).`,
}
: {
erfolg: `Zwischenstand gespeichert. ${ergebnis.offeneFragen} ${ergebnis.offeneFragen === 1 ? 'Frage ist' : 'Fragen sind'} noch ohne Punkte – erst dann gilt die Kontrolle als korrigiert.`,
};
}
/** Prüft den Lesezugriff und liefert den Stand. Wird von Seiten benutzt. */
export async function standZumLesen(azubiId: string, kontrolleId: string) {
const benutzer = await erfordereBenutzer();
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) return null;
const stand = sorgeFuerStand(azubiId, kontrolleId);
return { benutzer, bezug, stand };
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+211
View File
@@ -0,0 +1,211 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { ergebnisVon, staendeVon } from '@/lib/repos/kontrollen';
import { gesamtpunkte, zustandVon, type Zustand } from '@/lib/kontrollen/sicht';
import { verlauf } from '@/lib/kontrollen/bewertung';
import { alsZeitpunktText } from '@/lib/zeit';
import { kontrolleFreigeben, freigabeZurueck } from './aktionen';
export const metadata: Metadata = { title: 'Lernzielkontrollen' };
export const dynamic = 'force-dynamic';
const zustandsmarke: Record<Zustand, { text: string; ton: string; zeichen: string }> = {
gesperrt: { text: 'nicht freigegeben', ton: 'neutral', zeichen: '○' },
freigegeben: { text: 'freigegeben', ton: 'warnung', zeichen: '▶' },
abgegeben: { text: 'abgegeben', ton: 'warnung', zeichen: '▲' },
korrigiert: { text: 'korrigiert', ton: 'gut', zeichen: '✓' },
};
export default async function KontrollenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'azubi') {
const staende = staendeVon(benutzer.id);
const zeilen = alle.kontrollen.map((inhalt) => {
const stand = staende.get(inhalt.id);
const zustand = zustandVon({
azubiId: benutzer.id,
freigegebenAm: stand?.freigegebenAm ?? null,
abgegebenAm: stand?.abgegebenAm ?? null,
korrigiertAm: stand?.korrigiertAm ?? null,
});
const ergebnis = stand && stand.korrigiertAm ? ergebnisVon(stand.id, inhalt) : null;
return { inhalt, zustand, ergebnis, stand };
});
const korrigierte = zeilen
.filter((z) => z.ergebnis)
.map((z) => ({ titel: z.inhalt.titel, prozent: z.ergebnis!.prozent, note: z.ergebnis!.note }));
const stand = verlauf(korrigierte);
return (
<div className="stapel">
<div>
<h1>Lernzielkontrollen</h1>
<p className="leise">
{alle.kontrollen.length} Kontrollen · {korrigierte.length} korrigiert
{stand.anzahl > 0 ? ` · Schnitt ${stand.schnittProzent} %, Note ${zahl(stand.schnittNote)}` : ''}
</p>
</div>
<ul className="kontrollliste">
{zeilen.map(({ inhalt, zustand, ergebnis }) => {
const marke = zustandsmarke[zustand];
const offen = zustand === 'gesperrt';
return (
<li key={inhalt.id}>
{offen ? (
<div className="kontrollzeile kontrollzeile-gesperrt">
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
</div>
) : (
<Link href={`/kontrollen/${inhalt.id}`} className="kontrollzeile">
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
</Link>
)}
</li>
);
})}
</ul>
<p className="leise">
Eine Kontrolle wird von deinem Ausbilder freigegeben. Vorher sind die Fragen nicht einsehbar – die
Musterlösungen erst nach dem Absenden.
</p>
</div>
);
}
// --- Ausbilder ---
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Lernzielkontrollen</h1>
<p className="leise">Freigeben, korrigieren, Ergebnisse ansehen.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi }) => {
const staende = staendeVon(azubi.id);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<ul className="kontrollliste">
{alle.kontrollen.map((inhalt) => {
const stand = staende.get(inhalt.id);
const zustand = zustandVon({
azubiId: azubi.id,
freigegebenAm: stand?.freigegebenAm ?? null,
abgegebenAm: stand?.abgegebenAm ?? null,
korrigiertAm: stand?.korrigiertAm ?? null,
});
const marke = zustandsmarke[zustand];
const ergebnis = stand && stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
return (
<li key={inhalt.id}>
<div className="kontrollzeile">
<Link href={`/kontrollen/${inhalt.id}?azubi=${azubi.id}`} className="kontrollzeile-haupt">
<span className="kontroll-woche">Woche {inhalt.woche}</span>
<span className="kontroll-titel">
{inhalt.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{inhalt.fragen.length} Fragen · {gesamtpunkte(inhalt)} Punkte
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
</Link>
<div className="reihe" style={{ gap: '0.4rem' }}>
{ergebnis && stand?.korrigiertAm ? (
<span className="marke-status" data-ton="gut">
{ergebnis.prozent} % · Note {ergebnis.note}
</span>
) : null}
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
{zustand === 'gesperrt' ? (
<form action={kontrolleFreigeben}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="kontrolleId" value={inhalt.id} />
<button type="submit" className="knopf knopf-akzent">
Freigeben
</button>
</form>
) : null}
{zustand === 'freigegeben' ? (
<form action={freigabeZurueck}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="kontrolleId" value={inhalt.id} />
<button type="submit" className="knopf knopf-still">
Freigabe zurück
</button>
</form>
) : null}
</div>
</div>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
function Zeileninhalt({
inhalt,
marke,
ergebnis,
}: {
inhalt: { id: string; woche: number; titel: string; fragen: { punkte: number }[] };
marke: { text: string; ton: string; zeichen: string };
ergebnis: { prozent: number; note: number } | null;
}) {
return (
<>
<span className="kontroll-woche">Woche {inhalt.woche}</span>
<span className="kontroll-titel">
{inhalt.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{inhalt.fragen.length} Fragen · {inhalt.fragen.reduce((s, f) => s + f.punkte, 0)} Punkte
</span>
</span>
{ergebnis ? (
<span className="marke-status" data-ton="gut">
{ergebnis.prozent} % · Note {ergebnis.note}
</span>
) : null}
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
</>
);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+2
View File
@@ -13,6 +13,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -24,6 +25,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+20 -2
View File
@@ -7,6 +7,7 @@ import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { wartetAufKorrektur } from '@/lib/repos/kontrollen';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
@@ -152,7 +153,8 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds);
const berichte = wartetAufAbzeichnung(azubiIds);
const gesamt = abnahmen.length + berichte.length;
const kontrollen = wartetAufKorrektur(azubiIds);
const gesamt = abnahmen.length + berichte.length + kontrollen.length;
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
@@ -162,10 +164,26 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
{gesamt === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts offen. Unkorrigierte Lernzielkontrollen kommen hier dazu, sobald dieser Baustein steht.
Nichts offen – keine Abnahme, keine Korrektur, kein Berichtsheft.
</p>
) : (
<ul className="offen-liste">
{kontrollen.map((stand) => {
const kontrolle = alle.kontrolleNach.get(stand.kontrolleId);
const azubi = benutzerNachId(stand.benutzerId);
return (
<li key={stand.id}>
<span>
<Link href={`/kontrollen/${stand.kontrolleId}?azubi=${stand.benutzerId}`}>
<strong>Lernzielkontrolle</strong> {kontrolle?.titel ?? stand.kontrolleId} korrigieren
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
</li>
);
})}
{berichte.map((bericht) => {
const azubi = benutzerNachId(bericht.benutzerId);
return (
+129
View File
@@ -1546,3 +1546,132 @@ textarea[aria-invalid="true"] { border-color: var(--fehler); }
@media print {
.tagesnotizen, .vorschlagsliste, .formulierungshilfe { display: none; }
}
/* --- Lernzielkontrollen --- */
.kontrollliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
.kontrollzeile {
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.7rem 0.9rem;
text-decoration: none;
color: var(--text);
}
a.kontrollzeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
.kontrollzeile-gesperrt { opacity: 0.72; }
.kontrollzeile-haupt {
display: flex;
align-items: center;
gap: 0.8rem;
flex: 1 1 260px;
text-decoration: none;
color: var(--text);
}
.kontrollzeile-haupt:hover { color: var(--petrol); }
.kontroll-titel { flex: 1; font-weight: 650; }
.kontroll-woche {
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.8rem;
white-space: nowrap;
flex-shrink: 0;
}
.kontroll-woche-gross { font-size: 0.92rem; padding: 0.25rem 0.7rem; }
.kontrolle-kopf {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.speicherstand { font-variant-numeric: tabular-nums; }
.note-anzeige {
display: grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-size: 1.5rem;
font-weight: 700;
}
.note-vorlaeufig { background: var(--flaeche-kraeftig); color: var(--text-leise); }
.fragenliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; counter-reset: frage; }
.fragenliste > li {
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 0.9rem 1rem;
}
.frage-kopf {
display: grid;
grid-template-columns: 1.9rem 1fr auto;
gap: 0.6rem;
align-items: start;
margin-bottom: 0.6rem;
}
.frage-nummer {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.9rem;
}
.frage-text { margin: 0; font-weight: 600; line-height: 1.45; padding-top: 0.2rem; }
.frage-punkte {
white-space: nowrap;
font-size: 0.85rem;
color: var(--text-leise);
font-variant-numeric: tabular-nums;
padding-top: 0.3rem;
}
.korrektur-spalten {
display: grid;
gap: 0.8rem;
grid-template-columns: 1fr;
}
@media (min-width: 760px) {
.korrektur-spalten { grid-template-columns: 1fr 1fr; }
}
.musterloesung {
background: var(--gut-flaeche);
border-color: var(--gut);
}
.korrektur-bewertung {
display: flex;
gap: 0.75rem;
align-items: flex-end;
margin-top: 0.7rem;
flex-wrap: wrap;
}
.korrektur-bewertung input[type="number"] { width: 7rem; }
.ergebnis-balken { display: grid; gap: 0.35rem; }
@media print {
.kontrolle-kopf { position: static !important; }
}
@@ -0,0 +1,38 @@
-- Baustein 8: Lernzielkontrollen.
--
-- Fragen, Punkte und Musterlösungen stehen in der Inhaltsdatei unter
-- content/lernzielkontrollen/. Hier steht der Bearbeitungsstand eines Azubis:
-- wann freigegeben, wann abgegeben, welche Antworten, welche Punkte.
--
-- Die Musterlösung wird bewusst **nicht** mitkopiert. Sie darf den Browser
-- des Azubis vor der Abgabe nicht erreichen, und je weniger Stellen sie kennen,
-- desto weniger kann schiefgehen.
CREATE TABLE kontrolle (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
kontrolle_id TEXT NOT NULL,
freigegeben_am TEXT,
freigegeben_von TEXT REFERENCES benutzer (id),
abgegeben_am TEXT,
punkte REAL,
anmerkung TEXT,
korrigiert_von TEXT REFERENCES benutzer (id),
korrigiert_am TEXT,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX kontrolle_idx ON kontrolle (benutzer_id, kontrolle_id);
CREATE INDEX kontrolle_abgabe_idx ON kontrolle (abgegeben_am);
CREATE TABLE kontroll_antwort (
id TEXT PRIMARY KEY,
kontrolle_id TEXT NOT NULL REFERENCES kontrolle (id) ON DELETE CASCADE,
frage_id TEXT NOT NULL,
antwort TEXT NOT NULL DEFAULT '',
punkte REAL,
anmerkung TEXT,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX kontroll_antwort_idx ON kontroll_antwort (kontrolle_id, frage_id);
+43
View File
@@ -262,6 +262,45 @@ export const wochenbericht = sqliteTable(
],
);
export const kontrolle = sqliteTable(
'kontrolle',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
kontrolleId: text('kontrolle_id').notNull(),
freigegebenAm: text('freigegeben_am'),
freigegebenVon: text('freigegeben_von').references(() => benutzer.id),
abgegebenAm: text('abgegeben_am'),
punkte: real('punkte'),
anmerkung: text('anmerkung'),
korrigiertVon: text('korrigiert_von').references(() => benutzer.id),
korrigiertAm: text('korrigiert_am'),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [
uniqueIndex('kontrolle_idx').on(t.benutzerId, t.kontrolleId),
index('kontrolle_abgabe_idx').on(t.abgegebenAm),
],
);
export const kontrollAntwort = sqliteTable(
'kontroll_antwort',
{
id: text('id').primaryKey(),
kontrolleId: text('kontrolle_id')
.notNull()
.references(() => kontrolle.id, { onDelete: 'cascade' }),
frageId: text('frage_id').notNull(),
antwort: text('antwort').notNull().default(''),
punkte: real('punkte'),
anmerkung: text('anmerkung'),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [uniqueIndex('kontroll_antwort_idx').on(t.kontrolleId, t.frageId)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -275,6 +314,8 @@ export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
export type ChecklisteSchritt = typeof checklisteSchritt.$inferSelect;
export type Tagesnotiz = typeof tagesnotiz.$inferSelect;
export type Wochenbericht = typeof wochenbericht.$inferSelect;
export type Kontrolle = typeof kontrolle.$inferSelect;
export type KontrollAntwort = typeof kontrollAntwort.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = {
@@ -291,4 +332,6 @@ export const alleTabellen = {
checklisteSchritt,
tagesnotiz,
wochenbericht,
kontrolle,
kontrollAntwort,
};
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { begrenzePunkte, noteZu, verlauf, werteAus, type Fragenstand } from './bewertung';
function frage(moeglich: number, erreicht: number | null): Fragenstand {
return { frageId: `f${moeglich}-${erreicht}`, moeglich, erreicht };
}
describe('Notenschlüssel', () => {
it('trifft die amtlichen Grenzen', () => {
expect(noteZu(100).note).toBe(1);
expect(noteZu(92).note).toBe(1);
expect(noteZu(91).note).toBe(2);
expect(noteZu(81).note).toBe(2);
expect(noteZu(80).note).toBe(3);
expect(noteZu(67).note).toBe(3);
expect(noteZu(66).note).toBe(4);
expect(noteZu(50).note).toBe(4);
expect(noteZu(49).note).toBe(5);
expect(noteZu(30).note).toBe(5);
expect(noteZu(29).note).toBe(6);
expect(noteZu(0).note).toBe(6);
});
it('nennt den Notentext dazu', () => {
expect(noteZu(95).text).toBe('sehr gut');
expect(noteZu(55).text).toBe('ausreichend');
expect(noteZu(10).text).toBe('ungenügend');
});
});
describe('Auswertung', () => {
it('rechnet Punkte, Prozent und Note', () => {
const ergebnis = werteAus([frage(8, 7), frage(5, 4), frage(6, 5), frage(5, 4), frage(6, 5)]);
expect(ergebnis.erreicht).toBe(25);
expect(ergebnis.moeglich).toBe(30);
expect(ergebnis.prozent).toBe(83);
expect(ergebnis.note).toBe(2);
expect(ergebnis.vollstaendig).toBe(true);
});
it('zählt unbewertete Fragen als offen und das Ergebnis als vorläufig', () => {
const ergebnis = werteAus([frage(8, 8), frage(5, null)]);
expect(ergebnis.vollstaendig).toBe(false);
expect(ergebnis.offeneFragen).toBe(1);
expect(ergebnis.erreicht).toBe(8);
expect(ergebnis.moeglich).toBe(13);
});
it('gibt bei voller Punktzahl Note 1', () => {
const ergebnis = werteAus([frage(10, 10), frage(10, 10)]);
expect(ergebnis.prozent).toBe(100);
expect(ergebnis.note).toBe(1);
});
it('gibt bei null Punkten Note 6', () => {
const ergebnis = werteAus([frage(10, 0), frage(10, 0)]);
expect(ergebnis.prozent).toBe(0);
expect(ergebnis.note).toBe(6);
});
it('kommt mit einer Kontrolle ohne Fragen zurecht', () => {
const ergebnis = werteAus([]);
expect(ergebnis.moeglich).toBe(0);
expect(ergebnis.prozent).toBe(0);
expect(ergebnis.vollstaendig).toBe(false);
});
it('lässt sich durch negative Punkte nicht aus dem Tritt bringen', () => {
const ergebnis = werteAus([frage(10, -5)]);
expect(ergebnis.erreicht).toBe(0);
});
it('rechnet mit halben Punkten', () => {
const ergebnis = werteAus([frage(8, 6.5), frage(5, 2.5)]);
expect(ergebnis.erreicht).toBe(9);
expect(ergebnis.prozent).toBe(69);
expect(ergebnis.note).toBe(3);
});
});
describe('Punkte begrenzen', () => {
it('lässt gültige Werte durch', () => {
expect(begrenzePunkte(6, 8)).toBe(6);
expect(begrenzePunkte(6.5, 8)).toBe(6.5);
});
it('deckelt bei der möglichen Punktzahl', () => {
expect(begrenzePunkte(99, 8)).toBe(8);
});
it('macht aus negativen Werten null', () => {
expect(begrenzePunkte(-3, 8)).toBe(0);
});
it('macht aus Unsinn null', () => {
expect(begrenzePunkte(Number.NaN, 8)).toBe(0);
expect(begrenzePunkte(Number.POSITIVE_INFINITY, 8)).toBe(0);
});
});
describe('Verlauf mehrerer Kontrollen', () => {
const ergebnisse = [
{ titel: 'Woche 1', prozent: 80, note: 3 },
{ titel: 'Woche 3', prozent: 90, note: 2 },
{ titel: 'Woche 5', prozent: 70, note: 3 },
];
it('rechnet Schnitt, beste und schlechteste', () => {
const stand = verlauf(ergebnisse);
expect(stand.schnittProzent).toBe(80);
expect(stand.schnittNote).toBe(2.7);
expect(stand.beste).toBe(90);
expect(stand.schlechteste).toBe(70);
expect(stand.anzahl).toBe(3);
});
it('kommt ohne Ergebnisse zurecht', () => {
expect(verlauf([]).anzahl).toBe(0);
expect(verlauf([]).schnittProzent).toBe(0);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Auswertung einer Lernzielkontrolle.
*
* Reine Rechnung ohne Datenbank. Der Notenschlüssel ist der übliche der IHK
* für die Abschlussprüfung – wer sich daran gewöhnt, erlebt in der Prüfung
* keine Überraschung.
*/
export type Fragenstand = {
frageId: string;
moeglich: number;
erreicht: number | null;
};
export type Ergebnis = {
erreicht: number;
moeglich: number;
prozent: number;
note: number;
notentext: string;
/** Alle Fragen bewertet? Solange nicht, ist das Ergebnis vorläufig. */
vollstaendig: boolean;
offeneFragen: number;
};
/**
* Notenschlüssel der IHK (100-Punkte-Schlüssel).
*
* Die Grenzen sind die amtlichen; „ab" ist jeweils einschließlich.
*/
export const notenschluessel: { ab: number; note: number; text: string }[] = [
{ ab: 92, note: 1, text: 'sehr gut' },
{ ab: 81, note: 2, text: 'gut' },
{ ab: 67, note: 3, text: 'befriedigend' },
{ ab: 50, note: 4, text: 'ausreichend' },
{ ab: 30, note: 5, text: 'mangelhaft' },
{ ab: 0, note: 6, text: 'ungenügend' },
];
export function noteZu(prozent: number): { note: number; text: string } {
const stufe = notenschluessel.find((s) => prozent >= s.ab) ?? notenschluessel.at(-1)!;
return { note: stufe.note, text: stufe.text };
}
export function werteAus(fragen: Fragenstand[]): Ergebnis {
const moeglich = fragen.reduce((summe, f) => summe + Math.max(0, f.moeglich), 0);
// Unbewertete Fragen zählen als 0 – das Ergebnis ist dann aber „vorläufig".
const erreicht = fragen.reduce((summe, f) => summe + Math.max(0, f.erreicht ?? 0), 0);
const offeneFragen = fragen.filter((f) => f.erreicht === null).length;
const prozent = moeglich > 0 ? Math.round((erreicht / moeglich) * 100) : 0;
const { note, text } = noteZu(prozent);
return {
erreicht: runde(erreicht),
moeglich: runde(moeglich),
prozent,
note,
notentext: text,
vollstaendig: fragen.length > 0 && offeneFragen === 0,
offeneFragen,
};
}
/** Begrenzt vergebene Punkte auf das, was die Frage hergibt. */
export function begrenzePunkte(eingabe: number, moeglich: number): number {
if (!Number.isFinite(eingabe) || eingabe < 0) return 0;
return Math.min(runde(eingabe), moeglich);
}
/** Verlauf mehrerer Ergebnisse – für die Auswertung. */
export function verlauf(ergebnisse: { titel: string; prozent: number; note: number }[]): {
schnittProzent: number;
schnittNote: number;
beste: number;
schlechteste: number;
anzahl: number;
} {
if (ergebnisse.length === 0) {
return { schnittProzent: 0, schnittNote: 0, beste: 0, schlechteste: 0, anzahl: 0 };
}
const prozente = ergebnisse.map((e) => e.prozent);
const noten = ergebnisse.map((e) => e.note);
return {
schnittProzent: Math.round(prozente.reduce((a, b) => a + b, 0) / prozente.length),
schnittNote: Math.round((noten.reduce((a, b) => a + b, 0) / noten.length) * 10) / 10,
beste: Math.max(...prozente),
schlechteste: Math.min(...prozente),
anzahl: ergebnisse.length,
};
}
function runde(wert: number): number {
return Math.round(wert * 100) / 100;
}
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import {
darfBearbeiten,
darfFragenSehen,
gesamtpunkte,
sichtbareKontrolle,
zustandVon,
type Bearbeitungsstand,
} from './sicht';
import type { Kontrolle } from '../inhalte/schema';
import type { Betrachter } from '../auth/regeln';
const kontrolle: Kontrolle = {
id: 'woche-03',
woche: 3,
titel: 'Lernzielkontrolle Woche 3',
dauer_minuten: 45,
hinweis: 'Offene Fragen.',
fragen: [
{ id: 'f1', frage: 'Was ist die Blockgröße bei /27?', punkte: 8, musterloesung: 'GEHEIM-EINS: 32.' },
{ id: 'f2', frage: 'Wozu dient DHCP?', punkte: 5, musterloesung: 'GEHEIM-ZWEI: Adressvergabe.' },
],
};
const azubi: Betrachter = { id: 'azu-1', rolle: 'azubi', aktiv: 1 };
const andererAzubi: Betrachter = { id: 'azu-2', rolle: 'azubi', aktiv: 1 };
const ausbilder: Betrachter = { id: 'aus-1', rolle: 'ausbilder', aktiv: 1 };
const fremderAusbilder: Betrachter = { id: 'aus-9', rolle: 'ausbilder', aktiv: 1 };
const bezug = { azubiId: 'azu-1', ausbilderId: 'aus-1' };
function stand(teil: Partial<Bearbeitungsstand> = {}): Bearbeitungsstand {
return { azubiId: 'azu-1', freigegebenAm: null, abgegebenAm: null, ...teil };
}
/** Findet heraus, ob irgendwo im Ergebnis noch eine Musterlösung steckt. */
function enthaeltGeheimnis(wert: unknown): boolean {
return JSON.stringify(wert).includes('GEHEIM');
}
describe('Musterlösung vor dem Absenden', () => {
it('kommt beim gesperrten Zustand nicht mit', () => {
const sicht = sichtbareKontrolle(kontrolle, azubi, stand(), bezug);
expect(sicht.musterloesungSichtbar).toBe(false);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('kommt auch nach der Freigabe nicht mit, solange nicht abgegeben wurde', () => {
const sicht = sichtbareKontrolle(kontrolle, azubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }), bezug);
expect(sicht.musterloesungSichtbar).toBe(false);
expect(sicht.fragen.every((f) => f.musterloesung === null)).toBe(true);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('kommt nach dem Absenden mit', () => {
const sicht = sichtbareKontrolle(
kontrolle,
azubi,
stand({ freigegebenAm: '2026-09-21T07:00:00.000Z', abgegebenAm: '2026-09-21T08:00:00.000Z' }),
bezug,
);
expect(sicht.musterloesungSichtbar).toBe(true);
expect(sicht.fragen[0]!.musterloesung).toContain('GEHEIM-EINS');
});
it('kommt für den zuständigen Ausbilder jederzeit mit', () => {
const sicht = sichtbareKontrolle(kontrolle, ausbilder, stand(), bezug);
expect(sicht.musterloesungSichtbar).toBe(true);
expect(sicht.fragen[1]!.musterloesung).toContain('GEHEIM-ZWEI');
});
it('kommt für einen fremden Ausbilder nie mit', () => {
const sicht = sichtbareKontrolle(
kontrolle,
fremderAusbilder,
stand({ abgegebenAm: '2026-09-21T08:00:00.000Z' }),
bezug,
);
expect(sicht.musterloesungSichtbar).toBe(false);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('kommt für einen anderen Azubi nie mit', () => {
const sicht = sichtbareKontrolle(
kontrolle,
andererAzubi,
stand({ abgegebenAm: '2026-09-21T08:00:00.000Z' }),
bezug,
);
expect(sicht.musterloesungSichtbar).toBe(false);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('kommt bei gesperrtem Konto nie mit', () => {
const sicht = sichtbareKontrolle(
kontrolle,
{ ...azubi, aktiv: 0 },
stand({ abgegebenAm: '2026-09-21T08:00:00.000Z' }),
bezug,
);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('kommt ohne Ausbildungsverhältnis für den Ausbilder nicht mit', () => {
const sicht = sichtbareKontrolle(kontrolle, ausbilder, stand(), null);
expect(enthaeltGeheimnis(sicht)).toBe(false);
});
it('lässt Frage, Punkte und Titel immer durch – nur die Lösung fehlt', () => {
const sicht = sichtbareKontrolle(kontrolle, azubi, stand(), bezug);
expect(sicht.titel).toBe('Lernzielkontrolle Woche 3');
expect(sicht.fragen[0]!.frage).toContain('Blockgröße');
expect(sicht.fragen[0]!.punkte).toBe(8);
expect(sicht.gesamtpunkte).toBe(13);
});
});
describe('Fragen sehen', () => {
it('zeigt dem Azubi vor der Freigabe nichts', () => {
expect(darfFragenSehen(azubi, stand(), bezug)).toBe(false);
});
it('zeigt sie ihm ab der Freigabe', () => {
expect(darfFragenSehen(azubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }), bezug)).toBe(true);
});
it('zeigt sie dem zuständigen Ausbilder jederzeit – er muss sie vorher durchsehen', () => {
expect(darfFragenSehen(ausbilder, stand(), bezug)).toBe(true);
});
it('zeigt sie einem fremden Ausbilder nie', () => {
expect(darfFragenSehen(fremderAusbilder, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }), bezug)).toBe(false);
});
it('zeigt sie einem anderen Azubi nie', () => {
expect(darfFragenSehen(andererAzubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }), bezug)).toBe(false);
});
});
describe('Bearbeiten', () => {
it('geht nur zwischen Freigabe und Abgabe', () => {
expect(darfBearbeiten(azubi, stand())).toBe(false);
expect(darfBearbeiten(azubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }))).toBe(true);
expect(
darfBearbeiten(azubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z', abgegebenAm: '2026-09-21T08:00:00.000Z' })),
).toBe(false);
});
it('geht für den Ausbilder nie – er korrigiert, er schreibt nicht mit', () => {
expect(darfBearbeiten(ausbilder, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }))).toBe(false);
});
it('geht für einen anderen Azubi nie', () => {
expect(darfBearbeiten(andererAzubi, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }))).toBe(false);
});
it('geht bei gesperrtem Konto nicht', () => {
expect(darfBearbeiten({ ...azubi, aktiv: 0 }, stand({ freigegebenAm: '2026-09-21T07:00:00.000Z' }))).toBe(false);
});
});
describe('Zustand', () => {
it('benennt die vier Stufen', () => {
expect(zustandVon(stand())).toBe('gesperrt');
expect(zustandVon(stand({ freigegebenAm: 'x' }))).toBe('freigegeben');
expect(zustandVon(stand({ freigegebenAm: 'x', abgegebenAm: 'y' }))).toBe('abgegeben');
expect(zustandVon({ ...stand({ freigegebenAm: 'x', abgegebenAm: 'y' }), korrigiertAm: 'z' })).toBe('korrigiert');
});
});
describe('Gesamtpunkte', () => {
it('summiert die Punkte aller Fragen', () => {
expect(gesamtpunkte(kontrolle)).toBe(13);
});
it('kommt mit einer Kontrolle ohne Fragen zurecht', () => {
expect(gesamtpunkte({ fragen: [] })).toBe(0);
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { Kontrolle, Kontrollfrage } from '../inhalte/schema';
import { darfMusterloesungSehen, type Betrachter, type Ausbildungsbezug } from '../auth/regeln';
/**
* Die Fassung einer Lernzielkontrolle, die den Browser erreichen darf.
*
* Das ist die wichtigste Stelle dieses Bausteins. Der Auftrag ist eindeutig:
* Musterlösungen dürfen dem Azubi **erst nach dem Absenden** angezeigt werden
* und vorher **niemals** über die Schnittstelle abrufbar sein.
*
* Deshalb wird die Musterlösung hier serverseitig entfernt, bevor die Daten
* überhaupt in die Ausgabe gelangen – nicht erst in der Oberfläche versteckt.
* Wer den Seitenquelltext liest oder die Antwort der Serveraktion mitschneidet,
* findet nichts.
*/
export type SichtbareFrage = {
id: string;
frage: string;
punkte: number;
/** `null`, solange die Musterlösung nicht gezeigt werden darf. */
musterloesung: string | null;
};
export type SichtbareKontrolle = {
id: string;
woche: number;
titel: string;
dauerMinuten: number | null;
hinweis: string;
gesamtpunkte: number;
fragen: SichtbareFrage[];
/** Sagt der Oberfläche, warum die Musterlösung fehlt. */
musterloesungSichtbar: boolean;
};
export type Bearbeitungsstand = {
azubiId: string;
freigegebenAm: string | null;
abgegebenAm: string | null;
};
/**
* Baut die sichtbare Fassung.
*
* Nimmt bewusst den **ganzen** Betrachter und den Bearbeitungsstand entgegen,
* statt eines vorberechneten Merkmals – so kann kein Aufrufer versehentlich
* `true` übergeben.
*/
export function sichtbareKontrolle(
kontrolle: Kontrolle,
betrachter: Betrachter,
stand: Bearbeitungsstand,
bezug: Ausbildungsbezug,
): SichtbareKontrolle {
const darf = darfMusterloesungSehen(
betrachter,
{ azubiId: stand.azubiId, abgegebenAm: stand.abgegebenAm },
bezug,
);
return {
id: kontrolle.id,
woche: kontrolle.woche,
titel: kontrolle.titel,
dauerMinuten: kontrolle.dauer_minuten ?? null,
hinweis: kontrolle.hinweis,
gesamtpunkte: gesamtpunkte(kontrolle),
musterloesungSichtbar: darf,
fragen: kontrolle.fragen.map((frage) => ({
id: frage.id,
frage: frage.frage,
punkte: frage.punkte,
musterloesung: darf ? frage.musterloesung : null,
})),
};
}
export function gesamtpunkte(kontrolle: { fragen: Kontrollfrage[] }): number {
return kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0);
}
/**
* Darf der Azubi jetzt schreiben?
*
* Nur zwischen Freigabe und Abgabe. Vorher gibt es die Fragen gar nicht zu
* sehen, nachher ist die Kontrolle abgeschlossen.
*/
export function darfBearbeiten(betrachter: Betrachter, stand: Bearbeitungsstand): boolean {
if (betrachter.aktiv !== 1) return false;
if (betrachter.rolle !== 'azubi') return false;
if (betrachter.id !== stand.azubiId) return false;
return stand.freigegebenAm !== null && stand.abgegebenAm === null;
}
/**
* Darf der Betrachter die **Fragen** sehen?
*
* Der Ausbilder immer – er muss die Kontrolle vor der Freigabe durchsehen
* können. Der Azubi erst ab der Freigabe.
*/
export function darfFragenSehen(betrachter: Betrachter, stand: Bearbeitungsstand, bezug: Ausbildungsbezug): boolean {
if (betrachter.aktiv !== 1) return false;
if (betrachter.rolle === 'ausbilder') {
return Boolean(bezug && bezug.azubiId === stand.azubiId && bezug.ausbilderId === betrachter.id);
}
if (betrachter.id !== stand.azubiId) return false;
return stand.freigegebenAm !== null;
}
export type Zustand = 'gesperrt' | 'freigegeben' | 'abgegeben' | 'korrigiert';
export function zustandVon(stand: Bearbeitungsstand & { korrigiertAm?: string | null }): Zustand {
if (stand.korrigiertAm) return 'korrigiert';
if (stand.abgegebenAm) return 'abgegeben';
if (stand.freigegebenAm) return 'freigegeben';
return 'gesperrt';
}
+5 -3
View File
@@ -5,6 +5,7 @@ import { planVon } from '../repos/wochenplanung';
import { uebersicht as kartenUebersicht } from '../repos/karten';
import { anzahlAbgenommen } from '../repos/aufgaben';
import { anzahlAbgeschlossen } from '../repos/checklisten';
import { anzahlAbgegeben } from '../repos/kontrollen';
import { heute } from '../zeit';
import { aktuelleWoche } from './wochen';
import { wochenFortschritt, type Fortschritt } from './fortschritt';
@@ -79,13 +80,14 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
checklisten: {
erledigt: anzahlAbgeschlossen(azubiId, checklisten.map((c) => c.id)),
gesamt: checklisten.length,
},
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
kontrolle: {
erledigt: kontrolle ? anzahlAbgegeben(azubiId, [kontrolle.id]) : 0,
gesamt: kontrolle ? 1 : 0,
},
});
const nummern = plan.map((z) => z.wochenNummer);
+204
View File
@@ -0,0 +1,204 @@
import crypto from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
import { begrenzePunkte, werteAus, type Ergebnis, type Fragenstand } from '../kontrollen/bewertung';
import type { Kontrolle as Kontrollinhalt } from '../inhalte/schema';
import type { Kontrolle, KontrollAntwort } from '../db/schema';
/** Bearbeitungsstand der Lernzielkontrollen. */
export function standVon(benutzerId: string, kontrolleId: string): Kontrolle | null {
return (
datenbank()
.select()
.from(schema.kontrolle)
.where(and(eq(schema.kontrolle.benutzerId, benutzerId), eq(schema.kontrolle.kontrolleId, kontrolleId)))
.get() ?? null
);
}
export function standNachId(id: string): Kontrolle | null {
return datenbank().select().from(schema.kontrolle).where(eq(schema.kontrolle.id, id)).get() ?? null;
}
export function staendeVon(benutzerId: string): Map<string, Kontrolle> {
const zeilen = datenbank()
.select()
.from(schema.kontrolle)
.where(eq(schema.kontrolle.benutzerId, benutzerId))
.all();
return new Map(zeilen.map((z) => [z.kontrolleId, z]));
}
export function antwortenVon(standId: string): Map<string, KontrollAntwort> {
const zeilen = datenbank()
.select()
.from(schema.kontrollAntwort)
.where(eq(schema.kontrollAntwort.kontrolleId, standId))
.all();
return new Map(zeilen.map((z) => [z.frageId, z]));
}
/** Legt bei Bedarf eine Zeile an – noch ohne Freigabe. */
export function sorgeFuerStand(benutzerId: string, kontrolleId: string): Kontrolle {
const vorhanden = standVon(benutzerId, kontrolleId);
if (vorhanden) return vorhanden;
const datensatz: Kontrolle = {
id: crypto.randomUUID(),
benutzerId,
kontrolleId,
freigegebenAm: null,
freigegebenVon: null,
abgegebenAm: null,
punkte: null,
anmerkung: null,
korrigiertVon: null,
korrigiertAm: null,
geaendertAm: jetzt(),
};
datenbank().insert(schema.kontrolle).values(datensatz).run();
return datensatz;
}
export function freigeben(benutzerId: string, kontrolleId: string, ausbilderId: string): Kontrolle {
const stand = sorgeFuerStand(benutzerId, kontrolleId);
datenbank()
.update(schema.kontrolle)
.set({ freigegebenAm: jetzt(), freigegebenVon: ausbilderId, geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, stand.id))
.run();
return { ...stand, freigegebenAm: jetzt(), freigegebenVon: ausbilderId };
}
/** Freigabe zurücknehmen – nur solange nichts abgegeben wurde. */
export function freigabeZuruecknehmen(standId: string): void {
datenbank()
.update(schema.kontrolle)
.set({ freigegebenAm: null, freigegebenVon: null, geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, standId))
.run();
}
/**
* Eine einzelne Antwort speichern.
*
* Wird beim Tippen laufend aufgerufen – deshalb ein einzelner Schreibvorgang
* je Frage statt eines Rundumschlags über das ganze Formular.
*/
export function antwortSpeichern(standId: string, frageId: string, antwort: string): void {
datenbank()
.insert(schema.kontrollAntwort)
.values({
id: crypto.randomUUID(),
kontrolleId: standId,
frageId,
antwort,
punkte: null,
anmerkung: null,
geaendertAm: jetzt(),
})
.onConflictDoUpdate({
target: [schema.kontrollAntwort.kontrolleId, schema.kontrollAntwort.frageId],
set: { antwort, geaendertAm: jetzt() },
})
.run();
}
export function abgeben(standId: string): void {
datenbank()
.update(schema.kontrolle)
.set({ abgegebenAm: jetzt(), geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, standId))
.run();
}
/**
* Punkte und Anmerkungen des Ausbilders schreiben.
*
* Die Gesamtpunktzahl wird aus den Einzelpunkten gerechnet und mitgeschrieben,
* damit Listen und Auswertungen nicht jedes Mal alle Antworten laden müssen.
*/
export function korrigieren(
standId: string,
ausbilderId: string,
inhalt: Kontrollinhalt,
bewertungen: { frageId: string; punkte: number | null; anmerkung: string | null }[],
anmerkung: string | null,
): Ergebnis {
const db = datenbank();
const wann = jetzt();
const moeglichNach = new Map(inhalt.fragen.map((f) => [f.id, f.punkte]));
for (const bewertung of bewertungen) {
const moeglich = moeglichNach.get(bewertung.frageId);
if (moeglich === undefined) continue; // Frage gibt es nicht (mehr)
const punkte = bewertung.punkte === null ? null : begrenzePunkte(bewertung.punkte, moeglich);
db.insert(schema.kontrollAntwort)
.values({
id: crypto.randomUUID(),
kontrolleId: standId,
frageId: bewertung.frageId,
antwort: '',
punkte,
anmerkung: bewertung.anmerkung,
geaendertAm: wann,
})
.onConflictDoUpdate({
target: [schema.kontrollAntwort.kontrolleId, schema.kontrollAntwort.frageId],
set: { punkte, anmerkung: bewertung.anmerkung, geaendertAm: wann },
})
.run();
}
const ergebnis = ergebnisVon(standId, inhalt);
db.update(schema.kontrolle)
.set({
punkte: ergebnis.erreicht,
anmerkung,
korrigiertVon: ausbilderId,
korrigiertAm: ergebnis.vollstaendig ? wann : null,
geaendertAm: wann,
})
.where(eq(schema.kontrolle.id, standId))
.run();
return ergebnis;
}
/** Rechnet das Ergebnis aus den gespeicherten Einzelpunkten. */
export function ergebnisVon(standId: string, inhalt: Kontrollinhalt): Ergebnis {
const antworten = antwortenVon(standId);
const fragen: Fragenstand[] = inhalt.fragen.map((frage) => ({
frageId: frage.id,
moeglich: frage.punkte,
erreicht: antworten.get(frage.id)?.punkte ?? null,
}));
return werteAus(fragen);
}
/** Abgegebene, aber noch nicht fertig korrigierte Kontrollen – für „Was wartet auf mich". */
export function wartetAufKorrektur(benutzerIds: string[]): Kontrolle[] {
if (benutzerIds.length === 0) return [];
return datenbank()
.select()
.from(schema.kontrolle)
.all()
.filter((z) => benutzerIds.includes(z.benutzerId) && z.abgegebenAm !== null && z.korrigiertAm === null);
}
/** Zählt abgegebene Kontrollen einer Liste – für den Wochenfortschritt. */
export function anzahlAbgegeben(benutzerId: string, kontrollIds: string[]): number {
if (kontrollIds.length === 0) return 0;
const staende = staendeVon(benutzerId);
return kontrollIds.filter((id) => staende.get(id)?.abgegebenAm != null).length;
}