Baustein 9: Auswertung, Rahmenplanabdeckung, Feedbackgespraech

Die Abdeckung des Ausbildungsrahmenplans ist die Ansicht, die gegenueber
der IHK zaehlt. Sie unterscheidet vier Lagen statt "erledigt/offen":

  vermittelt   eine abgenommene Aufgabe oder ein abgeschlossener
               Checklisten-Durchlauf, oder zwei gelesene Handouts
  angefangen   etwas erledigt, aber noch nicht genug
  geplant      Inhalt vorhanden, nichts davon bearbeitet
  ohne Inhalt  zu dieser Position gibt es im Lehrgang gar nichts

Die vierte Lage ist die wichtigste: Sie ist keine Luecke beim Azubi,
sondern eine im Lehrgang, und wird getrennt ausgewiesen. Die Regel steht
als reine Funktion in src/lib/auswertung/abdeckung.ts, mit 17 Tests.

Das Feedbackgespraech laesst beide Seiten getrennt bewerten. Wo sie um
zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben - in der
Ansicht, in der Uebersicht und im PDF, alle drei ueber dieselbe Funktion
liegtWeitAuseinander(). Nach beidseitiger Bestaetigung ist das Gespraech
unveraenderlich und wird nur noch gelesen.

Durchstich in scripts/pruefungen/baustein-9.mjs: alle fuenf Luecken
erkannt, getrennte Spalten, Sperre serverseitig, PDF nur mit Anmeldung,
ein fremder Azubi bekommt 404 - auch auf das PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 14:08:49 +02:00
co-authored by Claude Opus 5
parent d226a6bae8
commit be2a674c7a
53 changed files with 3138 additions and 4 deletions
@@ -0,0 +1,145 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { inhaltVon } from '@/lib/repos/feedback';
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { Gespraechsformular } from '../Gespraechsformular';
import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ gespraechId: string }> }): Promise<Metadata> {
const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId);
return { title: gelesen ? `Feedbackgespräch ${alsDatumText(gelesen.gespraech.datum)}` : 'Feedbackgespräch' };
}
export default async function GespraechsSeite({ params }: { params: Promise<{ gespraechId: string }> }) {
const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId);
if (!gelesen) notFound();
const { benutzer, gespraech, ausbildung } = gelesen;
const vorlage = inhalte().feedback;
const inhalt = inhaltVon(gespraech, vorlage);
const summe = zusammenfassung(inhalt);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = benutzerNachId(ausbildung.azubiId);
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
if (!azubi) notFound();
const fertig = istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
});
const bereitsBestaetigt = istAzubi ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/feedback">Feedbackgespräche</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="gespraech-datum gespraech-datum-gross">{alsDatumText(gespraech.datum)}</span>
{fertig ? (
<span className="marke-status" data-ton="gut">
🔒 abgeschlossen und unveränderlich
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ in Arbeit
</span>
)}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>Feedbackgespräch</h1>
<p className="leise">
{azubi.name}
{ausbilder ? ` und ${ausbilder.name}` : ''} · {ausbildung.beruf}
</p>
</header>
{/* --- Bestätigungsstand --- */}
<section className="karte stapel-eng" aria-labelledby="bestaetigung">
<h2 id="bestaetigung" style={{ margin: 0, fontSize: '1rem' }}>
Bestätigung
</h2>
<ul className="offen-liste">
<li data-erledigt={gespraech.bestaetigtAusbilder === 1 ? 'ja' : 'nein'}>
<span>{ausbilder?.name ?? 'Ausbilder'}</span>
<span className="leise">
{gespraech.bestaetigtAusbilder === 1
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAusbilderAm)}`
: 'noch offen'}
</span>
</li>
<li data-erledigt={gespraech.bestaetigtAzubi === 1 ? 'ja' : 'nein'}>
<span>{azubi.name}</span>
<span className="leise">
{gespraech.bestaetigtAzubi === 1
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAzubiAm)}`
: 'noch offen'}
</span>
</li>
</ul>
{summe.abweichungen.length > 0 ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Unterschiedliche Sicht bei:</strong> {summe.abweichungen.join(', ')}. Genau darüber lohnt
das Gespräch.
</p>
) : null}
{bereitsBestaetigt && !fertig ? (
<form action={bestaetigungZurueck} className="nicht-drucken">
<input type="hidden" name="gespraechId" value={gespraech.id} />
<button type="submit" className="knopf knopf-still">
Bestätigung zurücknehmen
</button>
</form>
) : null}
</section>
<Gespraechsformular
gespraechId={gespraech.id}
inhalt={inhalt}
leitfaden={vorlage.leitfaden}
istAzubi={istAzubi}
gesperrt={fertig}
datum={gespraech.datum}
bereitsBestaetigt={bereitsBestaetigt}
/>
<div className="reihe nicht-drucken">
<a href={`/feedback/${gespraech.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Als PDF für die Akte
</a>
{!istAzubi && gespraech.bestaetigtAzubi === 0 && gespraech.bestaetigtAusbilder === 0 ? (
<form action={gespraechVerwerfen}>
<input type="hidden" name="gespraechId" value={gespraech.id} />
<button type="submit" className="knopf knopf-still">
Verwerfen
</button>
</form>
) : null}
</div>
{vorlage.hinweis ? (
<p className="leise" style={{ marginBottom: 0 }}>
{vorlage.hinweis}
</p>
) : null}
</article>
);
}