Baustein 5: Praxisaufgaben mit Anhaengen und Abnahme

- Status offen / in Arbeit / zur Abnahme / abgenommen, mit Zeitstempeln
- Notizfeld fuer den Azubi, Dateianhaenge bis 25 MB
- Speicherpfad nur aus selbst erzeugter UUID, nie aus dem Dateinamen
- Auslieferung ueber /anhaenge/<kennung> mit Rechtepruefung bei jedem Abruf,
  CSP sandbox und nosniff; SVG ausdruecklich nicht erlaubt
- Anhaenge haengen an Aufgabe UND Azubi
- Ausbilder hakt Kriterien einzeln ab, Note und Rueckmeldung, Abnahme sperrt
- Zurueckgeben zur Nacharbeit
- "Was auf dich wartet" zeigt offene Abnahmen
- Abgenommene Aufgaben zaehlen im Wochenfortschritt mit

Behoben: Next.js deckelt Serveraktionen ab Werk bei 1 MB - damit waere kein
Handyfoto durchgekommen. bodySizeLimit jetzt 30 MB, Vorabpruefung im Browser,
client_max_body_size im Proxy-Beispiel auf 30m.

Geaendert: Seiten verweigern fremde Daten mit 404 statt mit einem Fehler.
Eigene Seiten fuer "Nicht gefunden" und Stoerfaelle.

24 neue Tests, insgesamt 157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 12:18:54 +02:00
co-authored by Claude Opus 5
parent bb592ad2bd
commit ffbca01bdc
44 changed files with 1925 additions and 26 deletions
+310
View File
@@ -0,0 +1,310 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { anhaengeVon } from '@/lib/repos/anhaenge';
import { erfuellteKriterien, standVon } from '@/lib/repos/aufgaben';
import { istBild, megabyte } from '@/lib/anhaenge/dateien';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from '../StatusMarke';
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const aufgabe = inhalte().aufgabeNach.get(decodeURIComponent(id));
return { title: aufgabe ? `${aufgabe.id} – ${aufgabe.titel}` : 'Praxisaufgabe' };
}
export default async function AufgabenSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const { azubi: azubiParameter } = await searchParams;
const alle = inhalte();
const aufgabe = alle.aufgabeNach.get(decodeURIComponent(id));
if (!aufgabe) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Serverseitige Rechteprüfung – die Kennung aus der Adresszeile wird nie geglaubt.
erfordereSichtAufAzubi(benutzer, azubiId);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const stand = standVon(azubiId, aufgabe.id);
const status = stand?.status ?? 'offen';
const gesperrt = status === 'abgenommen';
const erfuellt = erfuellteKriterien(stand);
const anhaenge = anhaengeVon('aufgabe', `${aufgabe.id}::${azubiId}`);
const lernfeld = alle.lernfeldNach.get(aufgabe.lernfeld);
const positionen = aufgabe.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const abnehmer = stand?.abgenommenVon ? benutzerNachId(stand.abgenommenVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/aufgaben">Praxisaufgaben</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="aufgaben-nummer aufgaben-nummer-gross">{aufgabe.id}</span>
<StatusMarke status={status} />
<span className="leise">
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{aufgabe.titel}</h1>
<p className="handout-worum">{aufgabe.ziel}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke">
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{aufgabe.material.length > 0 ? (
<section className="karte" aria-labelledby="material">
<h2 id="material" style={{ fontSize: '1rem' }}>
Benötigtes Material
</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{aufgabe.material.map((posten, stelle) => (
<li key={stelle}>{posten}</li>
))}
</ul>
</section>
) : null}
{aufgabe.abschnitte.map((abschnitt, stelle) =>
abschnitt.titel ? (
<section key={stelle} aria-label={abschnitt.titel}>
<h2>{abschnitt.titel}</h2>
<div
className="fliesstext"
dangerouslySetInnerHTML={{ __html: verlinkeGlossar(abschnitt.html, alle.glossar) }}
/>
</section>
) : null,
)}
<section className="karte stapel" aria-labelledby="kriterien-liste">
<h2 id="kriterien-liste" style={{ marginBottom: 0 }}>
Bewertungskriterien
</h2>
<p className="leise" style={{ margin: 0 }}>
{erfuellt.length} von {aufgabe.kriterien.length} erfüllt · abgehakt wird vom Ausbilder
</p>
<ul className="kriterienliste">
{aufgabe.kriterien.map((text, stelle) => (
<li key={stelle} data-erfuellt={erfuellt.includes(stelle) ? 'ja' : 'nein'}>
<span aria-hidden="true">{erfuellt.includes(stelle) ? '✓' : '○'}</span>
<span>{text}</span>
<span className="nur-lesehilfe">{erfuellt.includes(stelle) ? ' – erfüllt' : ' – noch offen'}</span>
</li>
))}
</ul>
</section>
{/* --- Bearbeitung durch den Azubi --- */}
{istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="bearbeitung">
<h2 id="bearbeitung" style={{ marginBottom: 0 }}>
Deine Bearbeitung
</h2>
<div>
<p className="leise" style={{ margin: '0 0 0.35rem' }}>
Stand setzen
</p>
<StatusWaehlen aufgabeId={aufgabe.id} status={status} />
{status === 'zur_abnahme' ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
Abgegeben am {alsZeitpunktText(stand?.abgegebenAm)} – wartet auf die Abnahme.
</p>
) : null}
{gesperrt ? (
<p className="hinweis hinweis-gut" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
✓ Abgenommen am {alsZeitpunktText(stand?.abgenommenAm)}
{abnehmer ? ` von ${abnehmer.name}` : ''}.
</p>
) : null}
</div>
<NotizFormular aufgabeId={aufgabe.id} notiz={stand?.notiz ?? ''} gesperrt={gesperrt} />
<AnhangFormular aufgabeId={aufgabe.id} gesperrt={gesperrt} />
</section>
) : (
<section className="karte stapel" aria-labelledby="azubi-stand">
<h2 id="azubi-stand" style={{ marginBottom: 0 }}>
Bearbeitung von {azubi.name}
</h2>
<p className="leise" style={{ margin: 0 }}>
{stand?.begonnenAm ? `Begonnen ${alsZeitpunktText(stand.begonnenAm)}` : 'Noch nicht begonnen'}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
{stand?.abgenommenAm ? ` · abgenommen ${alsZeitpunktText(stand.abgenommenAm)}` : ''}
</p>
<div>
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.3rem' }}>Notizen</h3>
{stand?.notiz ? (
<p className="notiz-anzeige">{stand.notiz}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch keine Notizen.
</p>
)}
</div>
</section>
)}
{/* --- Anhänge --- */}
<section className="karte stapel" aria-labelledby="anhaenge">
<h2 id="anhaenge" style={{ marginBottom: 0 }}>
Anhänge{anhaenge.length > 0 ? ` (${anhaenge.length})` : ''}
</h2>
{anhaenge.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch nichts angehängt.
</p>
) : (
<ul className="anhangliste">
{anhaenge.map((eintrag) => (
<li key={eintrag.id}>
<a href={`/anhaenge/${eintrag.id}`} target="_blank" rel="noreferrer" className="anhang-kachel">
{istBild(eintrag.mimeTyp) ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={`/anhaenge/${eintrag.id}`} alt={eintrag.beschriftung ?? eintrag.dateiname} />
) : (
<span className="anhang-symbol" aria-hidden="true">
{eintrag.mimeTyp === 'application/pdf' ? 'PDF' : 'TXT'}
</span>
)}
<span className="anhang-name">{eintrag.beschriftung ?? eintrag.dateiname}</span>
<span className="leise">{megabyte(eintrag.groesse)}</span>
</a>
{istAzubi && !gesperrt ? (
<form action={anhangEntfernen} className="nicht-drucken">
<input type="hidden" name="anhangId" value={eintrag.id} />
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<button type="submit" className="knopf knopf-still">
Entfernen
</button>
</form>
) : null}
</li>
))}
</ul>
)}
</section>
{/* --- Bewertung und Abnahme --- */}
{!istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abnahme">
<h2 id="abnahme" style={{ marginBottom: 0 }}>
Bewerten und abnehmen
</h2>
<BewertungsFormular
aufgabeId={aufgabe.id}
azubiId={azubiId}
kriterien={aufgabe.kriterien}
erfuellt={erfuellt}
bewertung={stand?.bewertung ?? ''}
rueckmeldung={stand?.rueckmeldung ?? ''}
/>
<hr />
<div className="reihe">
<form action={aufgabeAbnehmen}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-akzent" disabled={status === 'abgenommen'}>
{status === 'abgenommen' ? 'Bereits abgenommen' : 'Aufgabe abnehmen'}
</button>
</form>
<form action={aufgabeZurueckgeben}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-zweit" disabled={status === 'offen'}>
Zur Nacharbeit zurückgeben
</button>
</form>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abnehmen sperrt die Aufgabe für den Azubi. Zurückgeben setzt sie auf &bdquo;in Arbeit&ldquo; – schreib in dem
Fall vorher eine Rückmeldung, sonst weiß er nicht, was fehlt.
</p>
</section>
) : null}
{/* --- Ergebnis, für beide sichtbar --- */}
{stand?.bewertung || stand?.rueckmeldung ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<h2 id="ergebnis" style={{ marginBottom: 0 }}>
Rückmeldung des Ausbilders
</h2>
{stand.bewertung ? (
<p style={{ margin: 0 }}>
<strong>Bewertung: {stand.bewertung}</strong>
</p>
) : null}
{stand.rueckmeldung ? <p className="notiz-anzeige">{stand.rueckmeldung}</p> : null}
</section>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{aufgabe.datei}</code>
</p>
</article>
);
}