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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HOECHSTGROESSE,
|
||||
istBild,
|
||||
istInnerhalb,
|
||||
megabyte,
|
||||
pruefeDatei,
|
||||
saubererName,
|
||||
speicherpfad,
|
||||
} from './dateien';
|
||||
|
||||
describe('Dateien prüfen', () => {
|
||||
it('nimmt ein Foto an', () => {
|
||||
expect(pruefeDatei('aufbau.jpg', 'image/jpeg', 2_000_000)).toEqual({ endung: '.jpg' });
|
||||
});
|
||||
|
||||
it('nimmt ein PDF an', () => {
|
||||
expect(pruefeDatei('netzplan.pdf', 'application/pdf', 500_000)).toEqual({ endung: '.pdf' });
|
||||
});
|
||||
|
||||
it('kommt mit einem Anhang am MIME-Typ zurecht', () => {
|
||||
expect(pruefeDatei('notiz.txt', 'text/plain; charset=utf-8', 100)).toEqual({ endung: '.txt' });
|
||||
});
|
||||
|
||||
it('weist unerlaubte Typen ab', () => {
|
||||
for (const typ of ['application/x-msdownload', 'image/svg+xml', 'text/html', 'application/zip', '']) {
|
||||
const ergebnis = pruefeDatei('etwas', typ, 1000);
|
||||
expect(ergebnis, `Typ ${typ} hätte abgewiesen werden müssen`).toHaveProperty('fehler');
|
||||
}
|
||||
});
|
||||
|
||||
it('weist SVG ausdrücklich ab – es kann Skripte enthalten', () => {
|
||||
expect(pruefeDatei('plan.svg', 'image/svg+xml', 1000)).toHaveProperty('fehler');
|
||||
});
|
||||
|
||||
it('weist eine leere Datei ab', () => {
|
||||
expect(pruefeDatei('leer.jpg', 'image/jpeg', 0)).toHaveProperty('fehler');
|
||||
});
|
||||
|
||||
it('weist eine zu große Datei ab und nennt die Grenze', () => {
|
||||
const ergebnis = pruefeDatei('riesig.jpg', 'image/jpeg', HOECHSTGROESSE + 1);
|
||||
expect(ergebnis).toHaveProperty('fehler');
|
||||
expect((ergebnis as { fehler: string }).fehler).toContain('25');
|
||||
});
|
||||
|
||||
it('nimmt genau die Höchstgröße noch an', () => {
|
||||
expect(pruefeDatei('gerade-noch.jpg', 'image/jpeg', HOECHSTGROESSE)).toEqual({ endung: '.jpg' });
|
||||
});
|
||||
|
||||
it('ist beim MIME-Typ unempfindlich gegen Großschreibung', () => {
|
||||
expect(pruefeDatei('foto.PNG', 'IMAGE/PNG', 1000)).toEqual({ endung: '.png' });
|
||||
});
|
||||
|
||||
it('weist einen unbrauchbaren Dateinamen ab', () => {
|
||||
expect(pruefeDatei('...', 'image/jpeg', 1000)).toHaveProperty('fehler');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dateinamen säubern', () => {
|
||||
it('lässt einen normalen Namen stehen', () => {
|
||||
expect(saubererName('Aufbau Halle 2.jpg')).toBe('Aufbau Halle 2.jpg');
|
||||
});
|
||||
|
||||
it('entfernt Verzeichnisanteile', () => {
|
||||
expect(saubererName('../../etc/passwd')).toBe('passwd');
|
||||
expect(saubererName('C:\\Users\\x\\geheim.jpg')).toBe('geheim.jpg');
|
||||
});
|
||||
|
||||
it('entfernt Steuerzeichen und Anführungszeichen', () => {
|
||||
expect(saubererName('bild"; rm -rf.jpg')).toBe('bild; rm -rf.jpg');
|
||||
expect(saubererName('zeile\numbruch.png')).toBe('zeileumbruch.png');
|
||||
});
|
||||
|
||||
it('zieht mehrfache Punkte zusammen', () => {
|
||||
expect(saubererName('foto..........jpg')).toBe('foto.jpg');
|
||||
});
|
||||
|
||||
it('kürzt sehr lange Namen', () => {
|
||||
expect(saubererName('a'.repeat(500) + '.jpg').length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
|
||||
it('gibt bei einem nur aus Punkten bestehenden Namen nichts zurück', () => {
|
||||
expect(saubererName('..')).toBe('');
|
||||
expect(saubererName(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Speicherpfad', () => {
|
||||
it('baut den Pfad aus Jahr und Kennung', () => {
|
||||
const kennung = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
|
||||
expect(speicherpfad(kennung, '.jpg', 2026)).toBe(`2026/${kennung}.jpg`);
|
||||
});
|
||||
|
||||
it('verweigert alles, was keine UUID ist – der Pfad darf nie aus einer Eingabe kommen', () => {
|
||||
for (const boese of ['../../etc/passwd', 'nicht-uuid', '', '../3f2504e0-4f89-41d3-9a0c-0305e82c3301']) {
|
||||
expect(() => speicherpfad(boese, '.jpg', 2026)).toThrow(/UUID/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pfad liegt im Anhangverzeichnis', () => {
|
||||
const wurzel = path.join(path.sep, 'daten', 'anhaenge');
|
||||
|
||||
it('erkennt einen Pfad darunter', () => {
|
||||
expect(istInnerhalb(wurzel, path.join(wurzel, '2026', 'a.jpg'))).toBe(true);
|
||||
});
|
||||
|
||||
it('erkennt das Verzeichnis selbst', () => {
|
||||
expect(istInnerhalb(wurzel, wurzel)).toBe(true);
|
||||
});
|
||||
|
||||
it('erkennt einen Ausbruch nach oben', () => {
|
||||
expect(istInnerhalb(wurzel, path.join(wurzel, '..', '..', 'etc', 'passwd'))).toBe(false);
|
||||
});
|
||||
|
||||
it('lässt sich nicht durch einen Namensanfang täuschen', () => {
|
||||
expect(istInnerhalb(wurzel, `${wurzel}-geheim/a.jpg`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anzeige', () => {
|
||||
it('schreibt Größen lesbar', () => {
|
||||
expect(megabyte(512)).toBe('512 Byte');
|
||||
expect(megabyte(2048)).toBe('2 KB');
|
||||
expect(megabyte(3_500_000)).toBe('3,3 MB');
|
||||
});
|
||||
|
||||
it('erkennt Bilder', () => {
|
||||
expect(istBild('image/jpeg')).toBe(true);
|
||||
expect(istBild('application/pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Regeln für hochgeladene Dateien – ohne Dateisystem, damit sie sich testen
|
||||
* lassen.
|
||||
*
|
||||
* Der Grundsatz: Nichts, was der Browser schickt, landet ungeprüft im
|
||||
* Dateisystem. Der Speicherpfad wird ausschließlich aus einer selbst erzeugten
|
||||
* Kennung gebildet, der Anzeigename wird nur gesäubert.
|
||||
*/
|
||||
|
||||
/** 25 MiB – ein Foto vom Aufbau passt locker, ein Video soll es nicht sein. */
|
||||
export const HOECHSTGROESSE = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Erlaubte Dateitypen. Bewusst eine kurze Positivliste: Fotos vom Aufbau,
|
||||
* Bildschirmfotos, ein Netzplan als PDF, gelegentlich eine Textdatei.
|
||||
*/
|
||||
export const ERLAUBTE_TYPEN: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'image/heic': '.heic',
|
||||
'application/pdf': '.pdf',
|
||||
'text/plain': '.txt',
|
||||
'text/csv': '.csv',
|
||||
};
|
||||
|
||||
export type Pruefergebnis = { fehler: string } | { endung: string };
|
||||
|
||||
export function pruefeDatei(name: string, mimeTyp: string, groesse: number): Pruefergebnis {
|
||||
if (groesse <= 0) {
|
||||
return { fehler: 'Die Datei ist leer.' };
|
||||
}
|
||||
if (groesse > HOECHSTGROESSE) {
|
||||
return {
|
||||
fehler: `Die Datei ist ${megabyte(groesse)} groß – erlaubt sind höchstens ${megabyte(HOECHSTGROESSE)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const typ = mimeTyp.split(';')[0]!.trim().toLowerCase();
|
||||
const endung = ERLAUBTE_TYPEN[typ];
|
||||
if (!endung) {
|
||||
return {
|
||||
fehler: `Dateien vom Typ „${typ || 'unbekannt'}" sind nicht erlaubt. Möglich sind Bilder, PDF und Textdateien.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (saubererName(name) === '') {
|
||||
return { fehler: 'Der Dateiname ist unbrauchbar.' };
|
||||
}
|
||||
|
||||
return { endung };
|
||||
}
|
||||
|
||||
export function megabyte(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Byte`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`.replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Säubert einen Dateinamen für die **Anzeige**. Er wird nie zum Bilden eines
|
||||
* Pfades benutzt – aber er landet im Content-Disposition-Kopf und in der
|
||||
* Oberfläche, und dort hat weder ein Zeilenumbruch noch ein Anführungszeichen
|
||||
* etwas verloren.
|
||||
*/
|
||||
export function saubererName(name: string): string {
|
||||
return path
|
||||
.basename(name)
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.replace(/["\\]/g, '')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[.\s]+|[.\s]+$/g, '')
|
||||
.slice(0, 120)
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Speicherpfad, relativ zum Anhangverzeichnis: `<jahr>/<kennung><endung>`.
|
||||
*
|
||||
* Nach Jahren unterteilt, damit nach drei Ausbildungsjahren nicht alles in
|
||||
* einem Verzeichnis liegt. Der Dateiname ist die selbst erzeugte Kennung –
|
||||
* damit ist ein Ausbruch aus dem Verzeichnis ausgeschlossen.
|
||||
*/
|
||||
export function speicherpfad(kennung: string, endung: string, jahr: number): string {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(kennung)) {
|
||||
throw new Error('Die Kennung eines Anhangs muss eine UUID sein.');
|
||||
}
|
||||
return `${jahr}/${kennung}${endung}`;
|
||||
}
|
||||
|
||||
/** Prüft, dass ein aus der Datenbank gelesener Pfad im Anhangverzeichnis liegt. */
|
||||
export function istInnerhalb(wurzel: string, vollerPfad: string): boolean {
|
||||
const auf = path.resolve(wurzel);
|
||||
const ziel = path.resolve(vollerPfad);
|
||||
return ziel === auf || ziel.startsWith(auf + path.sep);
|
||||
}
|
||||
|
||||
/** Fasst zusammen, was am Bildschirm über einen Anhang steht. */
|
||||
export function istBild(mimeTyp: string): boolean {
|
||||
return mimeTyp.startsWith('image/');
|
||||
}
|
||||
+15
-1
@@ -1,5 +1,5 @@
|
||||
import 'server-only';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { datenbank, schema } from '../db';
|
||||
import { angemeldeterBenutzer } from './sitzung';
|
||||
@@ -45,6 +45,20 @@ export function erfordereZugriffAufAzubi(betrachter: Betrachter, azubiId: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dasselbe für Seiten – endet mit „nicht gefunden" statt mit einem Fehler.
|
||||
*
|
||||
* Bewusst 404 und nicht 403: Ob es zu dieser Kennung überhaupt Daten gibt, geht
|
||||
* einen Unbefugten nichts an. Serveraktionen nehmen weiter die werfende
|
||||
* Fassung, dort wäre eine Weiterleitung sinnlos.
|
||||
*/
|
||||
export function erfordereSichtAufAzubi(betrachter: Betrachter, azubiId: string): void {
|
||||
const bezug = ausbildungVonAzubi(azubiId);
|
||||
if (!darfAzubiDatenSehen(betrachter, azubiId, bezug)) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
export function erfordereBewertungsrecht(betrachter: Betrachter, azubiId: string): void {
|
||||
const bezug = ausbildungVonAzubi(azubiId);
|
||||
if (!darfBewerten(betrachter, azubiId, bezug)) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Baustein 5: Praxisaufgaben und Dateianhänge.
|
||||
--
|
||||
-- Ziel, Auftrag, Material und Bewertungskriterien stehen in der Aufgabendatei
|
||||
-- unter content/aufgaben/. Hier steht, wie weit der Azubi damit ist.
|
||||
|
||||
CREATE TABLE aufgabe_status (
|
||||
id TEXT PRIMARY KEY,
|
||||
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||
aufgabe_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen', 'in_arbeit', 'zur_abnahme', 'abgenommen')),
|
||||
notiz TEXT NOT NULL DEFAULT '',
|
||||
-- Welche Bewertungskriterien der Ausbilder abgehakt hat, als JSON-Liste der
|
||||
-- Positionen in der Aufgabendatei, z. B. [0,2,3].
|
||||
kriterien_erfuellt TEXT NOT NULL DEFAULT '[]',
|
||||
bewertung TEXT,
|
||||
rueckmeldung TEXT,
|
||||
begonnen_am TEXT,
|
||||
abgegeben_am TEXT,
|
||||
abgenommen_von TEXT REFERENCES benutzer (id),
|
||||
abgenommen_am TEXT,
|
||||
geaendert_am TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX aufgabe_status_idx ON aufgabe_status (benutzer_id, aufgabe_id);
|
||||
CREATE INDEX aufgabe_status_status_idx ON aufgabe_status (status);
|
||||
|
||||
-- Anhänge sind bewusst allgemein gehalten: Sie hängen später auch an
|
||||
-- Checklisten-Durchläufen und Berichtsheftwochen.
|
||||
CREATE TABLE anhang (
|
||||
id TEXT PRIMARY KEY,
|
||||
bezug_typ TEXT NOT NULL CHECK (bezug_typ IN ('aufgabe', 'checkliste', 'bericht')),
|
||||
bezug_id TEXT NOT NULL,
|
||||
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||
dateiname TEXT NOT NULL,
|
||||
mime_typ TEXT NOT NULL,
|
||||
groesse INTEGER NOT NULL,
|
||||
-- Pfad relativ zum Anhangverzeichnis. Nie aus einer Eingabe gebildet.
|
||||
pfad TEXT NOT NULL,
|
||||
beschriftung TEXT,
|
||||
hochgeladen_am TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX anhang_bezug_idx ON anhang (bezug_typ, bezug_id);
|
||||
CREATE INDEX anhang_benutzer_idx ON anhang (benutzer_id);
|
||||
+63
-1
@@ -124,6 +124,56 @@ export const kartenVerlauf = sqliteTable(
|
||||
(t) => [index('karten_verlauf_benutzer_idx').on(t.benutzerId, t.bewertetAm)],
|
||||
);
|
||||
|
||||
export const aufgabenStatus = ['offen', 'in_arbeit', 'zur_abnahme', 'abgenommen'] as const;
|
||||
export type AufgabenStatus = (typeof aufgabenStatus)[number];
|
||||
|
||||
export const aufgabeStatus = sqliteTable(
|
||||
'aufgabe_status',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
benutzerId: text('benutzer_id')
|
||||
.notNull()
|
||||
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||
aufgabeId: text('aufgabe_id').notNull(),
|
||||
status: text('status', { enum: aufgabenStatus }).notNull().default('offen'),
|
||||
notiz: text('notiz').notNull().default(''),
|
||||
kriterienErfuellt: text('kriterien_erfuellt').notNull().default('[]'),
|
||||
bewertung: text('bewertung'),
|
||||
rueckmeldung: text('rueckmeldung'),
|
||||
begonnenAm: text('begonnen_am'),
|
||||
abgegebenAm: text('abgegeben_am'),
|
||||
abgenommenVon: text('abgenommen_von').references(() => benutzer.id),
|
||||
abgenommenAm: text('abgenommen_am'),
|
||||
geaendertAm: text('geaendert_am').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('aufgabe_status_idx').on(t.benutzerId, t.aufgabeId),
|
||||
index('aufgabe_status_status_idx').on(t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const bezugTypen = ['aufgabe', 'checkliste', 'bericht'] as const;
|
||||
export type BezugTyp = (typeof bezugTypen)[number];
|
||||
|
||||
export const anhang = sqliteTable(
|
||||
'anhang',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
bezugTyp: text('bezug_typ', { enum: bezugTypen }).notNull(),
|
||||
bezugId: text('bezug_id').notNull(),
|
||||
benutzerId: text('benutzer_id')
|
||||
.notNull()
|
||||
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||
dateiname: text('dateiname').notNull(),
|
||||
mimeTyp: text('mime_typ').notNull(),
|
||||
groesse: integer('groesse').notNull(),
|
||||
pfad: text('pfad').notNull(),
|
||||
beschriftung: text('beschriftung'),
|
||||
hochgeladenAm: text('hochgeladen_am').notNull(),
|
||||
},
|
||||
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
|
||||
);
|
||||
|
||||
export type Benutzer = typeof benutzer.$inferSelect;
|
||||
export type Sitzung = typeof sitzung.$inferSelect;
|
||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||
@@ -131,6 +181,18 @@ export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||
export type Wochenplanung = typeof wochenplanung.$inferSelect;
|
||||
export type Karte = typeof karte.$inferSelect;
|
||||
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
|
||||
export type AufgabeStatus = typeof aufgabeStatus.$inferSelect;
|
||||
export type Anhang = typeof anhang.$inferSelect;
|
||||
|
||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung, karte, kartenVerlauf };
|
||||
export const alleTabellen = {
|
||||
benutzer,
|
||||
sitzung,
|
||||
ausbildung,
|
||||
handoutGelesen,
|
||||
wochenplanung,
|
||||
karte,
|
||||
kartenVerlauf,
|
||||
aufgabeStatus,
|
||||
anhang,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
|
||||
import { gelesenSet } from '../repos/fortschritt';
|
||||
import { planVon } from '../repos/wochenplanung';
|
||||
import { uebersicht as kartenUebersicht } from '../repos/karten';
|
||||
import { anzahlAbgenommen } from '../repos/aufgaben';
|
||||
import { heute } from '../zeit';
|
||||
import { aktuelleWoche } from './wochen';
|
||||
import { wochenFortschritt, type Fortschritt } from './fortschritt';
|
||||
@@ -76,9 +77,9 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
|
||||
const fortschritt = wochenFortschritt({
|
||||
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
|
||||
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
|
||||
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
|
||||
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
|
||||
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
||||
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
|
||||
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
||||
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { datenbank, schema } from '../db';
|
||||
import { anhangWurzel } from '../pfade';
|
||||
import { jetzt } from '../zeit';
|
||||
import { istInnerhalb, pruefeDatei, saubererName, speicherpfad } from '../anhaenge/dateien';
|
||||
import type { Anhang, BezugTyp } from '../db/schema';
|
||||
|
||||
/** Dateianhänge: Datei im Dateisystem, Verweis in der Datenbank. */
|
||||
|
||||
export function anhaengeVon(bezugTyp: BezugTyp, bezugId: string): Anhang[] {
|
||||
return datenbank()
|
||||
.select()
|
||||
.from(schema.anhang)
|
||||
.where(and(eq(schema.anhang.bezugTyp, bezugTyp), eq(schema.anhang.bezugId, bezugId)))
|
||||
.orderBy(asc(schema.anhang.hochgeladenAm))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function anhangNachId(id: string): Anhang | null {
|
||||
return datenbank().select().from(schema.anhang).where(eq(schema.anhang.id, id)).get() ?? null;
|
||||
}
|
||||
|
||||
export type Ablageergebnis = { fehler: string } | { anhang: Anhang };
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei entgegen.
|
||||
*
|
||||
* Reihenfolge mit Absicht: erst prüfen, dann schreiben, dann eintragen.
|
||||
* Schlägt der Eintrag fehl, wird die Datei wieder entfernt – sonst sammeln
|
||||
* sich verwaiste Dateien im Volume an.
|
||||
*/
|
||||
export async function anhangAblegen(werte: {
|
||||
bezugTyp: BezugTyp;
|
||||
bezugId: string;
|
||||
benutzerId: string;
|
||||
datei: File;
|
||||
beschriftung?: string;
|
||||
}): Promise<Ablageergebnis> {
|
||||
const { datei } = werte;
|
||||
const geprueft = pruefeDatei(datei.name, datei.type, datei.size);
|
||||
if ('fehler' in geprueft) return geprueft;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const jahr = new Date().getUTCFullYear();
|
||||
const relativ = speicherpfad(id, geprueft.endung, jahr);
|
||||
const ziel = path.join(anhangWurzel, relativ);
|
||||
|
||||
if (!istInnerhalb(anhangWurzel, ziel)) {
|
||||
return { fehler: 'Ungültiger Ablageort.' };
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(ziel), { recursive: true });
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
await fs.writeFile(ziel, inhalt);
|
||||
|
||||
try {
|
||||
const datensatz: Anhang = {
|
||||
id,
|
||||
bezugTyp: werte.bezugTyp,
|
||||
bezugId: werte.bezugId,
|
||||
benutzerId: werte.benutzerId,
|
||||
dateiname: saubererName(datei.name),
|
||||
mimeTyp: datei.type.split(';')[0]!.trim().toLowerCase(),
|
||||
groesse: inhalt.byteLength,
|
||||
pfad: relativ,
|
||||
beschriftung: werte.beschriftung?.trim() || null,
|
||||
hochgeladenAm: jetzt(),
|
||||
};
|
||||
|
||||
datenbank().insert(schema.anhang).values(datensatz).run();
|
||||
return { anhang: datensatz };
|
||||
} catch (fehler) {
|
||||
await fs.rm(ziel, { force: true });
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Löscht Eintrag und Datei. Die Datei zuletzt – ein verwaister Eintrag wäre schlimmer. */
|
||||
export async function anhangLoeschen(id: string): Promise<void> {
|
||||
const eintrag = anhangNachId(id);
|
||||
if (!eintrag) return;
|
||||
|
||||
datenbank().delete(schema.anhang).where(eq(schema.anhang.id, id)).run();
|
||||
|
||||
const ziel = path.join(anhangWurzel, eintrag.pfad);
|
||||
if (istInnerhalb(anhangWurzel, ziel)) {
|
||||
await fs.rm(ziel, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest den Inhalt eines Anhangs. Der Pfad kommt aus der Datenbank, nie aus einer Eingabe. */
|
||||
export async function anhangLesen(eintrag: Anhang): Promise<Buffer | null> {
|
||||
const ziel = path.join(anhangWurzel, eintrag.pfad);
|
||||
if (!istInnerhalb(anhangWurzel, ziel)) return null;
|
||||
try {
|
||||
return await fs.readFile(ziel);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { datenbank, schema } from '../db';
|
||||
import { jetzt } from '../zeit';
|
||||
import type { AufgabeStatus, AufgabenStatus } from '../db/schema';
|
||||
|
||||
/** Bearbeitungsstand der Praxisaufgaben. */
|
||||
|
||||
export function standVon(benutzerId: string, aufgabeId: string): AufgabeStatus | null {
|
||||
return (
|
||||
datenbank()
|
||||
.select()
|
||||
.from(schema.aufgabeStatus)
|
||||
.where(and(eq(schema.aufgabeStatus.benutzerId, benutzerId), eq(schema.aufgabeStatus.aufgabeId, aufgabeId)))
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function staendeVon(benutzerId: string): Map<string, AufgabeStatus> {
|
||||
const zeilen = datenbank()
|
||||
.select()
|
||||
.from(schema.aufgabeStatus)
|
||||
.where(eq(schema.aufgabeStatus.benutzerId, benutzerId))
|
||||
.all();
|
||||
return new Map(zeilen.map((z) => [z.aufgabeId, z]));
|
||||
}
|
||||
|
||||
/** Legt bei Bedarf eine Zeile an – der Stand entsteht erst, wenn etwas passiert. */
|
||||
export function sorgeFuerStand(benutzerId: string, aufgabeId: string): AufgabeStatus {
|
||||
const vorhanden = standVon(benutzerId, aufgabeId);
|
||||
if (vorhanden) return vorhanden;
|
||||
|
||||
const datensatz: AufgabeStatus = {
|
||||
id: crypto.randomUUID(),
|
||||
benutzerId,
|
||||
aufgabeId,
|
||||
status: 'offen',
|
||||
notiz: '',
|
||||
kriterienErfuellt: '[]',
|
||||
bewertung: null,
|
||||
rueckmeldung: null,
|
||||
begonnenAm: null,
|
||||
abgegebenAm: null,
|
||||
abgenommenVon: null,
|
||||
abgenommenAm: null,
|
||||
geaendertAm: jetzt(),
|
||||
};
|
||||
|
||||
datenbank().insert(schema.aufgabeStatus).values(datensatz).run();
|
||||
return datensatz;
|
||||
}
|
||||
|
||||
/** Die abgehakten Kriterien als Positionsliste. */
|
||||
export function erfuellteKriterien(stand: AufgabeStatus | null | undefined): number[] {
|
||||
if (!stand) return [];
|
||||
try {
|
||||
const gelesen: unknown = JSON.parse(stand.kriterienErfuellt);
|
||||
if (!Array.isArray(gelesen)) return [];
|
||||
return gelesen.filter((n): n is number => Number.isInteger(n) && n >= 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function schreibe(id: string, werte: Partial<AufgabeStatus>): void {
|
||||
datenbank()
|
||||
.update(schema.aufgabeStatus)
|
||||
.set({ ...werte, geaendertAm: jetzt() })
|
||||
.where(eq(schema.aufgabeStatus.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Der Azubi setzt den Bearbeitungsstand. */
|
||||
export function statusSetzen(benutzerId: string, aufgabeId: string, status: AufgabenStatus): AufgabeStatus {
|
||||
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||
|
||||
const werte: Partial<AufgabeStatus> = { status };
|
||||
if (status === 'in_arbeit' && !stand.begonnenAm) werte.begonnenAm = jetzt();
|
||||
if (status === 'zur_abnahme') werte.abgegebenAm = jetzt();
|
||||
if (status === 'offen') {
|
||||
werte.abgegebenAm = null;
|
||||
werte.begonnenAm = null;
|
||||
}
|
||||
if (status !== 'abgenommen') {
|
||||
// Eine zurückgenommene Abgabe ist keine Abnahme mehr.
|
||||
werte.abgenommenAm = null;
|
||||
werte.abgenommenVon = null;
|
||||
}
|
||||
|
||||
schreibe(stand.id, werte);
|
||||
return { ...stand, ...werte };
|
||||
}
|
||||
|
||||
export function notizSetzen(benutzerId: string, aufgabeId: string, notiz: string): void {
|
||||
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||
schreibe(stand.id, { notiz });
|
||||
}
|
||||
|
||||
/** Der Ausbilder hakt Kriterien ab und gibt eine Rückmeldung. */
|
||||
export function bewertungSetzen(
|
||||
benutzerId: string,
|
||||
aufgabeId: string,
|
||||
werte: { kriterien: number[]; bewertung: string | null; rueckmeldung: string | null },
|
||||
): void {
|
||||
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||
schreibe(stand.id, {
|
||||
kriterienErfuellt: JSON.stringify([...new Set(werte.kriterien)].sort((a, b) => a - b)),
|
||||
bewertung: werte.bewertung,
|
||||
rueckmeldung: werte.rueckmeldung,
|
||||
});
|
||||
}
|
||||
|
||||
export function abnehmen(benutzerId: string, aufgabeId: string, ausbilderId: string): void {
|
||||
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||
schreibe(stand.id, { status: 'abgenommen', abgenommenVon: ausbilderId, abgenommenAm: jetzt() });
|
||||
}
|
||||
|
||||
/** Zurückgeben: der Azubi soll nacharbeiten. */
|
||||
export function zurueckgeben(benutzerId: string, aufgabeId: string): void {
|
||||
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||
schreibe(stand.id, { status: 'in_arbeit', abgegebenAm: null, abgenommenAm: null, abgenommenVon: null });
|
||||
}
|
||||
|
||||
/** Alle Aufgaben, die auf eine Abnahme warten – für „Was wartet auf mich". */
|
||||
export function wartetAufAbnahme(benutzerIds: string[]): AufgabeStatus[] {
|
||||
if (benutzerIds.length === 0) return [];
|
||||
return datenbank()
|
||||
.select()
|
||||
.from(schema.aufgabeStatus)
|
||||
.where(eq(schema.aufgabeStatus.status, 'zur_abnahme'))
|
||||
.all()
|
||||
.filter((z) => benutzerIds.includes(z.benutzerId));
|
||||
}
|
||||
|
||||
/** Wie viele Aufgaben einer Liste sind abgenommen? */
|
||||
export function anzahlAbgenommen(benutzerId: string, aufgabeIds: string[]): number {
|
||||
const staende = staendeVon(benutzerId);
|
||||
return aufgabeIds.filter((id) => staende.get(id)?.status === 'abgenommen').length;
|
||||
}
|
||||
Reference in New Issue
Block a user