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:
co-authored by
Claude Opus 5
parent
7c4a051347
commit
d226a6bae8
@@ -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('.', ',');
|
||||
}
|
||||
@@ -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('.', ',');
|
||||
}
|
||||
@@ -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('.', ',');
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user