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:
co-authored by
Claude Opus 5
parent
bb592ad2bd
commit
ffbca01bdc
@@ -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 „in Arbeit“ – 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user