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
+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('.', ',');
}