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 (
+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;
}