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 (
|
||||
|
||||
Reference in New Issue
Block a user