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
@@ -0,0 +1,21 @@
import type { AufgabenStatus } from '@/lib/db/schema';
/**
* Status einer Praxisaufgabe. Nie nur über die Farbe – jede Stufe trägt ein
* eigenes Zeichen und ihren Namen.
*/
export const statusText: Record<AufgabenStatus, { text: string; ton: string; zeichen: string }> = {
offen: { text: 'offen', ton: 'neutral', zeichen: '○' },
in_arbeit: { text: 'in Arbeit', ton: 'warnung', zeichen: '◐' },
zur_abnahme: { text: 'zur Abnahme', ton: 'warnung', zeichen: '▲' },
abgenommen: { text: 'abgenommen', ton: 'gut', zeichen: '✓' },
};
export function StatusMarke({ status }: { status: AufgabenStatus }) {
const marke = statusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,225 @@
'use client';
import { useActionState, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
anhangHochladen,
bewerten,
eigeneNotizSpeichern,
eigenenStatusSetzen,
type Rueckmeldung,
} from '../aktionen';
import type { AufgabenStatus } from '@/lib/db/schema';
export function StatusWaehlen({ aufgabeId, status }: { aufgabeId: string; status: AufgabenStatus }) {
const stufen: { wert: AufgabenStatus; text: string }[] = [
{ wert: 'offen', text: 'Offen' },
{ wert: 'in_arbeit', text: 'In Arbeit' },
{ wert: 'zur_abnahme', text: 'Zur Abnahme' },
];
return (
<form action={eigenenStatusSetzen} className="statuswahl">
<input type="hidden" name="aufgabeId" value={aufgabeId} />
{stufen.map((stufe) => (
<button
key={stufe.wert}
type="submit"
name="status"
value={stufe.wert}
className={`knopf ${status === stufe.wert ? '' : 'knopf-zweit'}`}
disabled={status === stufe.wert || status === 'abgenommen'}
aria-pressed={status === stufe.wert}
>
{stufe.text}
</button>
))}
</form>
);
}
export function NotizFormular({
aufgabeId,
notiz,
gesperrt,
}: {
aufgabeId: string;
notiz: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(eigeneNotizSpeichern, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="aufgaben-notiz">Deine Notizen</label>
<textarea
id="aufgaben-notiz"
name="notiz"
defaultValue={notiz}
rows={8}
maxLength={8000}
disabled={gesperrt}
placeholder="Was hast du gemacht, was ist aufgefallen, wo hakte es? Diese Notizen helfen dir später beim Berichtsheft."
/>
{gesperrt ? (
<p className="leise" style={{ margin: 0 }}>
Die Aufgabe ist abgenommen und damit gesperrt.
</p>
) : (
<div>
<Absenden className="knopf knopf-zweit">Notizen speichern</Absenden>
</div>
)}
</form>
);
}
/** Muss zur Prüfung auf dem Server passen – siehe lib/anhaenge/dateien.ts. */
const HOECHSTGROESSE = 25 * 1024 * 1024;
export function AnhangFormular({ aufgabeId, gesperrt }: { aufgabeId: string; gesperrt: boolean }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(anhangHochladen, {});
const [namen, setzeNamen] = useState<string[]>([]);
const [zuGross, setzeZuGross] = useState<string[]>([]);
const feld = useRef<HTMLInputElement>(null);
if (gesperrt) return null;
function beiAuswahl(dateien: FileList | null) {
const liste = Array.from(dateien ?? []);
setzeNamen(liste.map((d) => d.name));
// Vorab im Browser prüfen: Eine zu große Datei erst hochzuladen und dann
// abzulehnen kostet den Azubi im Zweifel Minuten am Mobilfunk.
setzeZuGross(liste.filter((d) => d.size > HOECHSTGROESSE).map((d) => d.name));
}
return (
<form
action={(daten) => {
setzeNamen([]);
setzeZuGross([]);
if (feld.current) feld.current.value = '';
return aktion(daten);
}}
className="stapel-eng"
>
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="anhang-datei">Dateien anhängen</label>
<input
id="anhang-datei"
ref={feld}
name="datei"
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,text/csv"
onChange={(e) => beiAuswahl(e.target.files)}
/>
<p className="leise" style={{ margin: 0 }}>
Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF. Höchstens 25 MB je Datei.
</p>
{zuGross.length > 0 ? (
<p className="hinweis hinweis-fehler" role="alert" style={{ margin: 0 }}>
Zu groß (mehr als 25 MB): {zuGross.join(', ')}. Bitte die Datei verkleinern.
</p>
) : namen.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Ausgewählt: {namen.join(', ')}
</p>
) : null}
<div>
<Absenden className="knopf knopf-zweit" wartetext="Wird übertragen …">
Hochladen
</Absenden>
</div>
</form>
);
}
export function BewertungsFormular({
aufgabeId,
azubiId,
kriterien,
erfuellt,
bewertung,
rueckmeldung,
}: {
aufgabeId: string;
azubiId: string;
kriterien: string[];
erfuellt: number[];
bewertung: string;
rueckmeldung: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(bewerten, {});
const [angehakt, setzeAngehakt] = useState<number[]>(erfuellt);
function umschalten(stelle: number) {
setzeAngehakt((vorher) =>
vorher.includes(stelle) ? vorher.filter((n) => n !== stelle) : [...vorher, stelle],
);
}
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<input type="hidden" name="azubiId" value={azubiId} />
<fieldset className="kriterien">
<legend>
Bewertungskriterien{' '}
<span className="leise">
({angehakt.length} von {kriterien.length} erfüllt)
</span>
</legend>
{kriterien.map((text, stelle) => (
<label key={stelle} className="ankreuzfeld kriterium">
<input
type="checkbox"
name="kriterium"
value={stelle}
checked={angehakt.includes(stelle)}
onChange={() => umschalten(stelle)}
/>
<span style={{ fontWeight: 400 }}>{text}</span>
</label>
))}
</fieldset>
<div className="feld">
<label htmlFor="bewertung">Note oder kurze Bewertung</label>
<input
id="bewertung"
name="bewertung"
type="text"
maxLength={60}
defaultValue={bewertung}
placeholder={'z. B. "gut" oder die Note 2'}
/>
</div>
<div className="feld">
<label htmlFor="rueckmeldung">Rückmeldung an den Azubi</label>
<textarea
id="rueckmeldung"
name="rueckmeldung"
rows={5}
maxLength={4000}
defaultValue={rueckmeldung}
placeholder="Was war gut, was fehlt noch? Konkret genug, dass er damit weiterarbeiten kann."
/>
</div>
<div>
<Absenden className="knopf">Bewertung speichern</Absenden>
</div>
</form>
);
}
+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>
);
}
+186
View File
@@ -0,0 +1,186 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer, erfordereBewertungsrecht, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { anhangAblegen, anhangLoeschen, anhangNachId } from '@/lib/repos/anhaenge';
import {
abnehmen,
bewertungSetzen,
notizSetzen,
statusSetzen,
standVon,
zurueckgeben,
} from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(aufgabeId: string) {
revalidatePath('/aufgaben');
revalidatePath(`/aufgaben/${aufgabeId}`);
revalidatePath('/');
}
function aufgabeGibtEs(aufgabeId: string): boolean {
return inhalte().aufgabeNach.has(aufgabeId);
}
// --- Der Azubi an seiner eigenen Aufgabe -----------------------------------
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const status = String(formular.get('status') ?? '') as AufgabenStatus;
if (!aufgabeGibtEs(aufgabeId)) return;
if (!aufgabenStatus.includes(status)) return;
// Abnehmen darf nur der Ausbilder.
if (status === 'abgenommen') return;
statusSetzen(benutzer.id, aufgabeId, status);
aktualisiere(aufgabeId);
}
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const notiz = String(formular.get('notiz') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
if (notiz.length > 8000) return { fehler: 'Die Notiz ist zu lang – höchstens 8000 Zeichen.' };
notizSetzen(benutzer.id, aufgabeId, notiz);
aktualisiere(aufgabeId);
return { erfolg: 'Notiz gespeichert.' };
}
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') {
return { fehler: 'Die Aufgabe ist abgenommen – sie lässt sich nicht mehr ändern.' };
}
const dateien = formular.getAll('datei').filter((d): d is File => d instanceof File && d.size > 0);
if (dateien.length === 0) return { fehler: 'Keine Datei ausgewählt.' };
const beschriftung = String(formular.get('beschriftung') ?? '');
const fehler: string[] = [];
let angelegt = 0;
for (const datei of dateien) {
const ergebnis = await anhangAblegen({
bezugTyp: 'aufgabe',
// Der Bezug ist Aufgabe **und** Azubi – sonst sähe ein zweiter Azubi
// die Anhänge des ersten an derselben Aufgabe.
bezugId: `${aufgabeId}::${benutzer.id}`,
benutzerId: benutzer.id,
datei,
beschriftung: dateien.length === 1 ? beschriftung : undefined,
});
if ('fehler' in ergebnis) {
fehler.push(`${datei.name}: ${ergebnis.fehler}`);
} else {
angelegt += 1;
}
}
aktualisiere(aufgabeId);
if (angelegt === 0) return { fehler: fehler.join(' · ') };
if (fehler.length > 0) {
return { fehler: `${angelegt} übernommen, aber: ${fehler.join(' · ')}` };
}
return { erfolg: `${angelegt} ${angelegt === 1 ? 'Anhang' : 'Anhänge'} hinzugefügt.` };
}
export async function anhangEntfernen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
const anhangId = String(formular.get('anhangId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const eintrag = anhangNachId(anhangId);
if (!eintrag) return;
// Nur der eigene Anhang, und nur solange die Aufgabe nicht abgenommen ist.
if (eintrag.benutzerId !== benutzer.id) return;
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') return;
await anhangLoeschen(anhangId);
aktualisiere(aufgabeId);
}
// --- Der Ausbilder an der Aufgabe seines Azubis ----------------------------
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const aufgabe = inhalte().aufgabeNach.get(aufgabeId);
if (!aufgabe) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const kriterien = formular
.getAll('kriterium')
.map((wert) => Number(wert))
.filter((n) => Number.isInteger(n) && n >= 0 && n < aufgabe.kriterien.length);
const bewertung = String(formular.get('bewertung') ?? '').trim();
const rueckmeldung = String(formular.get('rueckmeldung') ?? '').trim();
if (bewertung.length > 60) return { fehler: 'Die Bewertung ist zu lang – höchstens 60 Zeichen.' };
if (rueckmeldung.length > 4000) return { fehler: 'Die Rückmeldung ist zu lang – höchstens 4000 Zeichen.' };
bewertungSetzen(azubiId, aufgabeId, {
kriterien,
bewertung: bewertung || null,
rueckmeldung: rueckmeldung || null,
});
aktualisiere(aufgabeId);
return { erfolg: `Bewertung gespeichert – ${kriterien.length} von ${aufgabe.kriterien.length} Kriterien erfüllt.` };
}
export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
abnehmen(azubiId, aufgabeId, ausbilder.id);
aktualisiere(aufgabeId);
}
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
zurueckgeben(azubiId, aufgabeId);
aktualisiere(aufgabeId);
}
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer();
erfordereZugriffAufAzubi(benutzer, azubiId);
}
+115
View File
@@ -0,0 +1,115 @@
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 { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke';
export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic';
export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{alle.aufgaben.length} Aufgaben · Stand deiner {meine.length === 1 ? 'Auszubildenden' : 'Auszubildenden'}
</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);
const wartend = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'zur_abnahme');
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
{wartend.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {wartend.length} {wartend.length === 1 ? 'Abnahme' : 'Abnahmen'} offen
</span>
) : (
<span className="leise">nichts zur Abnahme</span>
)}
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}?azubi=${azubi.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
// --- Sicht des Azubis ---
const staende = staendeVon(benutzer.id);
const abgenommen = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'abgenommen').length;
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{abgenommen} von {alle.aufgaben.length} abgenommen
</p>
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen} · {aufgabe.kriterien.length} Kriterien
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { useEffect } from 'react';
/**
* Auffangnetz für den angemeldeten Bereich.
*
* Verweigerte Berechtigungen landen **nicht** hier, sondern auf der Seite
* „Nicht gefunden" – siehe erfordereSichtAufAzubi. Hier bleibt der echte
* Störfall übrig. Der Fehlertext selbst wird im Produktionsbetrieb von Next.js
* geschwärzt und steht nur im Serverprotokoll; deshalb gibt es hier keine
* Fallunterscheidung nach Meldung, sondern nur das Kennzeichen zur Fehlersuche.
*/
export default function Fehlerseite({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[lernbar] Fehler in der Oberfläche:', error);
}, [error]);
return (
<div className="karte-grund stapel" style={{ maxWidth: '38rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
⚠
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Da ist etwas schiefgegangen</h1>
<p className="leise">
Der Vorgang ließ sich nicht abschließen. Der Fehler steht im Protokoll des Servers.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
<button type="button" className="knopf knopf-zweit" onClick={reset}>
Noch einmal versuchen
</button>
</div>
{error.digest ? (
<p className="leise" style={{ marginBottom: 0 }}>
Kennzeichen für die Fehlersuche: <code>{error.digest}</code>
</p>
) : null}
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -18,6 +19,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link';
/**
* „Nicht gefunden" im angemeldeten Bereich.
*
* Diese Seite erscheint auch dann, wenn jemand Daten anfragt, die ihn nichts
* angehen. Die Meldung ist deshalb absichtlich unbestimmt: Ob es die Daten
* gibt, wird nicht verraten.
*/
export default function NichtGefunden() {
return (
<div className="karte-grund stapel" style={{ maxWidth: '36rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
🔍
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Nicht gefunden</h1>
<p className="leise">
Diese Seite gibt es nicht – oder sie gehört nicht zu deinem Bereich.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
</div>
</div>
);
}
+46 -10
View File
@@ -3,7 +3,10 @@ import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, heute, kalenderwocheText } from '@/lib/zeit';
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
export const dynamic = 'force-dynamic';
@@ -135,15 +138,48 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
</section>
)}
<section className="karte" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem' }}>
Was auf dich wartet
</h2>
<p className="leise" style={{ marginBottom: 0 }}>
Offene Abnahmen, unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte erscheinen
hier, sobald diese Bausteine stehen.
</p>
</section>
<WartetAufMich ausbilderId={ausbilderId} />
</div>
);
}
// --- Was auf den Ausbilder wartet ------------------------------------------
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const abnahmen = wartetAufAbnahme(meine.map(({ azubi }) => azubi.id));
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem', marginBottom: 0 }}>
Was auf dich wartet
</h2>
{abnahmen.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
hier dazu, sobald diese Bausteine stehen.
</p>
) : (
<ul className="offen-liste">
{abnahmen.map((stand) => {
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
const azubi = benutzerNachId(stand.benutzerId);
return (
<li key={stand.id}>
<span>
<Link href={`/aufgaben/${stand.aufgabeId}?azubi=${stand.benutzerId}`}>
<strong>{stand.aufgabeId}</strong> {aufgabe?.titel ?? 'Aufgabe'}
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
</li>
);
})}
</ul>
)}
</section>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { wochensicht } from '@/lib/planung/sicht';
@@ -37,7 +37,7 @@ export default async function WochenSeite({
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
// Server – nicht die Oberfläche und nicht die Adresszeile.
erfordereZugriffAufAzubi(benutzer, azubiId);
erfordereSichtAufAzubi(benutzer, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) notFound();
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { erfordereAusbilder, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
@@ -35,7 +35,7 @@ export default async function WochenplanSeite({ searchParams }: { searchParams:
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
erfordereBewertungsrecht(ausbilder, azubiId);
erfordereSichtAufAzubi(ausbilder, azubiId);
const azubi = benutzerNachId(azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
+52
View File
@@ -0,0 +1,52 @@
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge';
import { istBild } from '@/lib/anhaenge/dateien';
export const dynamic = 'force-dynamic';
/**
* Liefert einen Anhang aus.
*
* Zwei Dinge sind hier wichtig:
* 1. Der Pfad kommt ausschließlich aus der Datenbank. Es gibt keinen Weg,
* über die Adresse eine beliebige Datei anzufordern.
* 2. Die Berechtigung wird bei **jedem** Abruf geprüft. Wer den Anhang
* hochgeladen hat, darf ihn sehen; sein Ausbilder auch, sonst niemand.
*/
export async function GET(_anfrage: Request, kontext: { params: Promise<{ id: string }> }) {
const benutzer = await angemeldeterBenutzer();
if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 });
const { id } = await kontext.params;
const eintrag = anhangNachId(id);
if (!eintrag) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(eintrag.benutzerId);
if (!darfAzubiDatenSehen(benutzer, eintrag.benutzerId, bezug)) {
// Bewusst 404 statt 403: Ob es diesen Anhang gibt, geht Fremde nichts an.
return new Response('Nicht gefunden.', { status: 404 });
}
const inhalt = await anhangLesen(eintrag);
if (!inhalt) return new Response('Die Datei fehlt im Speicher.', { status: 410 });
// Bilder und PDF dürfen im Browser aufgehen, alles andere wird geladen.
const anzeigbar = istBild(eintrag.mimeTyp) || eintrag.mimeTyp === 'application/pdf';
const art = anzeigbar ? 'inline' : 'attachment';
return new Response(new Uint8Array(inhalt), {
headers: {
'Content-Type': eintrag.mimeTyp,
'Content-Length': String(inhalt.byteLength),
'Content-Disposition': `${art}; filename="${eintrag.dateiname}"; filename*=UTF-8''${encodeURIComponent(eintrag.dateiname)}`,
// Kein öffentlicher Zwischenspeicher – der Inhalt hängt am angemeldeten Benutzer.
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
// Schutz davor, dass ein hochgeladenes SVG oder HTML im Kontext der
// Anwendung ausgeführt wird. Anhänge sind Daten, kein Programm.
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
},
});
}
+124
View File
@@ -1100,3 +1100,127 @@ a.glossarbegriff:hover {
.bewerten { gap: 0.4rem; }
.bewerten-knopf { min-height: 84px; }
}
/* --- Praxisaufgaben --- */
.aufgaben-nummer {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.8rem;
padding: 0.15rem 0.55rem;
border-radius: var(--radius-klein);
background: var(--bernstein);
color: #26170a;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
}
.aufgaben-nummer-gross { font-size: 1rem; padding: 0.25rem 0.75rem; }
.aufgabenliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.aufgabenzeile {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
text-decoration: none;
color: var(--text);
}
.aufgabenzeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
.aufgaben-titel { flex: 1; font-weight: 650; }
.statuswahl { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.kriterienliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.kriterienliste li {
display: grid;
grid-template-columns: 1.2rem 1fr;
gap: 0.5rem;
align-items: start;
padding: 0.35rem 0;
border-bottom: 1px solid var(--rand-zart);
}
.kriterienliste li[data-erfuellt="ja"] { color: var(--gut); }
.kriterienliste li[data-erfuellt="ja"] > span:first-child { font-weight: 700; }
.kriterien {
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.75rem 0.9rem;
margin: 0;
}
.kriterien legend { font-weight: 650; padding: 0 0.35rem; font-size: 0.95rem; }
.kriterium { padding: 0.3rem 0; }
.kriterium + .kriterium { border-top: 1px solid var(--rand-zart); }
.notiz-anzeige {
white-space: pre-wrap;
background: var(--grund);
border: 1px solid var(--rand-zart);
border-radius: var(--radius-klein);
padding: 0.6rem 0.8rem;
margin: 0;
line-height: 1.55;
}
/* --- Anhänge --- */
.anhangliste {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.anhangliste li { display: grid; gap: 0.3rem; }
.anhang-kachel {
display: grid;
gap: 0.3rem;
text-decoration: none;
color: var(--text);
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 0.5rem;
}
.anhang-kachel:hover { border-color: var(--petrol); color: var(--text); }
.anhang-kachel img {
width: 100%;
height: 110px;
object-fit: cover;
border-radius: var(--radius-klein);
background: var(--flaeche);
}
.anhang-symbol {
display: grid;
place-items: center;
height: 110px;
border-radius: var(--radius-klein);
background: var(--flaeche-kraeftig);
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text-leise);
}
.anhang-name {
font-size: 0.88rem;
font-weight: 600;
overflow-wrap: anywhere;
}
input[type="file"] {
width: 100%;
padding: 0.5rem;
border: 1px dashed var(--rand);
border-radius: var(--radius-klein);
background: var(--grund);
color: var(--text);
font: inherit;
min-height: 44px;
}