Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft, Lernzielkontrollen und Feedbackgespraech lassen sich unter Verwaltung -> Module fuer die ganze Lern-Bar ab- und einschalten. Wochenplan, Handouts, Glossar, Auswertung und Verwaltung bleiben immer an. Gesperrt wird an drei Stellen, nicht im Layout (Next rendert Layouts beim Seitenwechsel darunter nicht neu): Seiten mit erfordereModul, Routen fuer PDF, Anhaenge und Lernstand mit modulAn, alle 37 Serveraktionen der Module mit pruefeModul. Die Anmeldepruefung kommt jeweils zuerst, damit ein Unangemeldeter nicht am 404 abliest, was abgeschaltet ist. Ein abgeschaltetes Modul traegt nirgends bei: Wochenplan, Fortschritt, Was-wartet, Mein Stand, Ergebnisverlauf, Rahmenplanabdeckung, Vorschlaege fuers Berichtsheft, Karten im Handout, Kurzbefehle im Manifest. Die Lernstand-Schnittstelle antwortet mit 410, das Geraet verwirft daraufhin seine Offline-Warteschlange. Abschalten loescht nichts. Gespeichert in modul_einstellung (Migration 0010); fehlt die Zeile, ist das Modul an, eine bestehende Datenbank verhaelt sich also wie vorher. 12 Tests fuer die Regeln, Abschnitt A8 im Abnahmelauf, Anleitung Kapitel 9 mit Bildschirmfoto, README, E43. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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';
|
||
import { erfordereModul } from '@/lib/module/pruefen';
|
||
|
||
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();
|
||
erfordereModul('aufgaben');
|
||
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>
|
||
);
|
||
}
|