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