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
@@ -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 (