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
+409
View File
@@ -0,0 +1,409 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
import { verlauf } from '@/lib/kontrollen/bewertung';
export const metadata: Metadata = { title: 'Auswertung' };
export const dynamic = 'force-dynamic';
const stufenTon: Record<Abdeckungsstufe, string> = {
abgedeckt: 'gut',
angefangen: 'warnung',
offen: 'neutral',
ohne_inhalt: 'fehler',
};
const stufenZeichen: Record<Abdeckungsstufe, string> = {
abgedeckt: '✓',
angefangen: '◐',
offen: '○',
ohne_inhalt: '!',
};
export default async function AuswertungsSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
const benutzer = await erfordereBenutzer();
const { azubi: azubiParameter } = await searchParams;
if (benutzer.rolle === 'azubi') {
return <AzubiAuswertung azubiId={benutzer.id} name={benutzer.name} />;
}
const meine = ausbildungenVonAusbilder(benutzer.id);
if (meine.length === 0) {
return (
<div className="stapel">
<h1>Auswertung</h1>
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
</div>
);
}
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
erfordereSichtAufAzubi(benutzer, azubiId);
const azubi = benutzerNachId(azubiId)!;
return (
<div className="stapel">
<div>
<h1>Auswertung</h1>
<p className="leise">{azubi.name}</p>
</div>
{meine.length > 1 ? (
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
{meine.map(({ azubi: person }) => (
<Link
key={person.id}
href={`/auswertung?azubi=${person.id}`}
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
>
{person.name}
</Link>
))}
</nav>
) : null}
<Rahmenplan azubiId={azubiId} />
<Kontrollverlauf azubiId={azubiId} />
<Lernfelder azubiId={azubiId} />
</div>
);
}
// --- Sicht des Azubis ------------------------------------------------------
function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return (
<div className="stapel">
<div>
<h1>Dein Stand</h1>
<p className="leise">{name}</p>
</div>
<div className="raster">
<Kachel
titel="Handouts gelesen"
wert={`${stand.handouts.erledigt} von ${stand.handouts.gesamt}`}
anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)}
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
/>
<Kachel
titel="Merkkarten, die sitzen"
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
/>
<Kachel
titel="Praxisaufgaben abgenommen"
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
fuss={
stand.aufgaben.zurAbnahme > 0
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
: `${stand.aufgaben.offen} noch offen`
}
/>
<Kachel
titel="Berichtsheft abgezeichnet"
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
fuss={
stand.berichtsheft.fehlendeWochen > 0
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
: stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
</div>
{stand.berichtsheft.fehlendeWochen > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
{stand.berichtsheft.fehlendeWochen}{' '}
{stand.berichtsheft.fehlendeWochen === 1 ? 'Woche' : 'Wochen'} ohne Berichtsheftblatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung.{' '}
<Link href="/berichtsheft">Jetzt nachtragen</Link>.
</p>
) : null}
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />
</div>
);
}
function Kachel({
titel,
wert,
anteil,
fuss,
ton,
}: {
titel: string;
wert: string;
anteil: number;
fuss: string;
ton?: string;
}) {
return (
<section className="karte stapel-eng">
<h2 style={{ fontSize: '0.9rem', marginBottom: 0, color: 'var(--text-leise)' }}>{titel}</h2>
<strong style={{ fontSize: '1.5rem' }}>{wert}</strong>
<div className="balken">
<span style={{ width: `${anteil}%` }} />
</div>
<span className="leise" style={{ color: ton === 'fehler' ? 'var(--fehler)' : undefined }}>
{fuss}
</span>
</section>
);
}
// --- Rahmenplanabdeckung ---------------------------------------------------
function Rahmenplan({ azubiId }: { azubiId: string }) {
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId);
const gruppen = nachGruppen(positionen);
const gesamt = bilanz(positionen);
return (
<section className="stapel" aria-labelledby="rahmenplan">
<div>
<h2 id="rahmenplan" style={{ marginBottom: '0.2rem' }}>
Abdeckung des Ausbildungsrahmenplans
</h2>
<p className="leise" style={{ margin: 0 }}>
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine
Position, wenn es dazu eine abgenommene Praxisaufgabe oder einen abgeschlossenen
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts.
</p>
</div>
<div className="karte-grund stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
{gesamt.abgedeckt} von {gesamt.gesamt} Positionen vermittelt
</strong>
<span className="leise">{gesamt.prozent} %</span>
</div>
<div className="balken" style={{ height: '14px' }}>
<span style={{ width: `${gesamt.prozent}%` }} />
</div>
<div className="reihe" style={{ gap: '0.5rem' }}>
<span className="marke-status" data-ton="gut">
✓ {gesamt.abgedeckt} vermittelt
</span>
<span className="marke-status" data-ton="warnung">
◐ {gesamt.angefangen} angefangen
</span>
<span className="marke-status">○ {gesamt.offen} geplant</span>
{gesamt.ohneInhalt > 0 ? (
<span className="marke-status" data-ton="fehler">
! {gesamt.ohneInhalt} ohne Inhalt
</span>
) : null}
</div>
</div>
{gesamt.ohneInhalt > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
Zu {gesamt.ohneInhalt} {gesamt.ohneInhalt === 1 ? 'Position' : 'Positionen'} gibt es im Lehrgang
überhaupt keinen Inhalt.
</strong>{' '}
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang – dort hilft kein Lernen, sondern nur ein
neues Handout oder eine neue Aufgabe unter <code>content/</code>.
</p>
) : null}
{gruppen.map((gruppe) => (
<details key={gruppe.gruppe} className="abdeckungsgruppe karte" open={gruppe.bilanz.ohneInhalt > 0}>
<summary>
<strong>{gruppennamen[gruppe.gruppe] ?? gruppe.gruppe}</strong>
<span className="leise">
{' '}
– {gruppe.bilanz.abgedeckt} von {gruppe.bilanz.gesamt} vermittelt
</span>
</summary>
<ul className="abdeckungsliste">
{gruppe.positionen.map((position) => (
<li key={position.id} data-stufe={position.stufe}>
<span className="abdeckung-kuerzel">{position.id}</span>
<span className="abdeckung-titel">
{position.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{position.verordnung}
{position.geplant > 0
? ` · ${position.nachweise.filter((n) => n.erledigt).length} von ${position.geplant} Nachweisen`
: ''}
</span>
{position.nachweise.length > 0 ? (
<span className="nachweisliste">
{position.nachweise.map((nachweis) => (
<span
key={`${nachweis.art}-${nachweis.id}`}
className="nachweis"
data-erledigt={nachweis.erledigt ? 'ja' : 'nein'}
title={`${nachweis.titel}${nachweis.erledigtAm ? ` – ${nachweis.erledigtAm}` : ''}`}
>
{nachweis.erledigt ? '✓' : '○'} {nachweis.id}
</span>
))}
</span>
) : null}
</span>
<span className="marke-status" data-ton={stufenTon[position.stufe]}>
{stufenZeichen[position.stufe]} {stufenText[position.stufe]}
</span>
</li>
))}
</ul>
</details>
))}
</section>
);
}
// --- Lernfelder ------------------------------------------------------------
function Lernfelder({ azubiId }: { azubiId: string }) {
const { lernfelder } = rahmenplanabdeckung(azubiId);
const jahre = [1, 2, 3];
return (
<section className="stapel" aria-labelledby="lernfelder">
<div>
<h2 id="lernfelder" style={{ marginBottom: '0.2rem' }}>
Lernfelder der Berufsschule
</h2>
<p className="leise" style={{ margin: 0 }}>
Nach derselben Regel bewertet. Der Unterricht selbst findet in der Schule statt – hier steht, was im
Betrieb dazu angekommen ist.
</p>
</div>
{jahre.map((jahr) => {
const eigene = lernfelder.filter((l) => l.jahr === jahr);
if (eigene.length === 0) return null;
return (
<div key={jahr} className="karte stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>{jahr}. Ausbildungsjahr</h3>
<ul className="abdeckungsliste">
{eigene.map((lernfeld) => (
<li key={lernfeld.id} data-stufe={lernfeld.stufe}>
<span className="abdeckung-kuerzel">{lernfeld.id}</span>
<span className="abdeckung-titel">
{lernfeld.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{lernfeld.stunden} Stunden
</span>
</span>
<span className="marke-status" data-ton={stufenTon[lernfeld.stufe]}>
{stufenZeichen[lernfeld.stufe]} {stufenText[lernfeld.stufe]}
</span>
</li>
))}
</ul>
</div>
);
})}
</section>
);
}
// --- Ergebnisverlauf -------------------------------------------------------
function Kontrollverlauf({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
}
function ErgebnisTabelle({
ergebnisse,
}: {
ergebnisse: {
id: string;
titel: string;
woche: number;
prozent: number;
note: number;
notentext: string;
erreicht: number;
moeglich: number;
korrigiertAm: string;
}[];
}) {
const stand = verlauf(ergebnisse);
return (
<section className="stapel" aria-labelledby="verlauf">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="verlauf" style={{ margin: 0 }}>
Lernzielkontrollen
</h2>
{stand.anzahl > 0 ? (
<span className="leise">
Schnitt {stand.schnittProzent} % · Note {stand.schnittNote.toString().replace('.', ',')} · beste{' '}
{stand.beste} %
</span>
) : null}
</div>
{ergebnisse.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch keine korrigierte Lernzielkontrolle.
</p>
) : (
<ol className="verlaufsliste">
{ergebnisse.map((ergebnis) => (
<li key={ergebnis.id}>
<span className="kontroll-woche">Woche {ergebnis.woche}</span>
<span className="verlauf-titel">
{ergebnis.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · korrigiert{' '}
{ergebnis.korrigiertAm}
</span>
</span>
<span className="verlauf-balken">
<span className="balken">
<span style={{ width: `${ergebnis.prozent}%` }} />
</span>
<span className="leise">{ergebnis.prozent} %</span>
</span>
<span className={`note-anzeige note-klein note-${ergebnis.note}`} title={ergebnis.notentext}>
{ergebnis.note}
</span>
</li>
))}
</ol>
)}
</section>
);
}
function anteil(erledigt: number, gesamt: number): number {
if (gesamt <= 0) return 0;
return Math.round((erledigt / gesamt) * 100);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
@@ -0,0 +1,282 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { gespraechBestaetigen, gespraechSpeichern, type Rueckmeldung } from './aktionen';
import { liegtWeitAuseinander, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
/**
* Der Bogen für das Gespräch.
*
* Jede Seite trägt ihre eigene Spalte ein: der Ausbilder seine Einschätzung
* und die Freitexte, der Azubi seine Selbsteinschätzung. Wo beide deutlich
* auseinanderliegen, wird das hervorgehoben – genau darüber lohnt das
* Gespräch.
*/
export function Gespraechsformular({
gespraechId,
inhalt,
leitfaden,
istAzubi,
gesperrt,
datum,
bereitsBestaetigt,
}: {
gespraechId: string;
inhalt: Gespraechsinhalt;
leitfaden: { abschnitt: string; fragen: string[] }[];
istAzubi: boolean;
gesperrt: boolean;
datum: string;
bereitsBestaetigt: boolean;
}) {
const [speicherstand, speichern] = useActionState<Rueckmeldung, FormData>(gespraechSpeichern, {});
const [bestaetigungsstand, bestaetigen] = useActionState<Rueckmeldung, FormData>(gespraechBestaetigen, {});
const eigeneSpalte = istAzubi ? 'azubi' : 'ausbilder';
return (
<form action={speichern} className="stapel">
<input type="hidden" name="gespraechId" value={gespraechId} />
{/* Nach Abschluss steht das Datum schon groß im Kopf – ein zweites,
gesperrtes Feld daneben sagt nichts dazu. */}
{!istAzubi && !gesperrt ? (
<div className="feld" style={{ maxWidth: '14rem' }}>
<label htmlFor="datum">Datum des Gesprächs</label>
<input id="datum" name="datum" type="date" defaultValue={datum} />
</div>
) : null}
{/* --- Bewertungsbogen --- */}
<section className="stapel-eng" aria-labelledby="bogen">
<div>
<h2 id="bogen" style={{ marginBottom: '0.15rem' }}>
Bewertungsbogen
</h2>
<p className="leise" style={{ margin: 0 }}>
{inhalt.skala[0]?.wert} = {inhalt.skala[0]?.text} bis {inhalt.skala.at(-1)?.wert} ={' '}
{inhalt.skala.at(-1)?.text}.{' '}
{istAzubi
? 'Du trägst deine Selbsteinschätzung ein – die Spalte des Ausbilders ist für dich gesperrt.'
: 'Du trägst deine Einschätzung ein; die Selbsteinschätzung kommt vom Azubi.'}
</p>
</div>
<ul className="kriterienbogen">
{inhalt.kriterien.map((kriterium) => {
const bewertung = inhalt.bewertungen.find((b) => b.kriteriumId === kriterium.id);
const eigener = eigeneSpalte === 'azubi' ? bewertung?.azubi : bewertung?.ausbilder;
const anderer = eigeneSpalte === 'azubi' ? bewertung?.ausbilder : bewertung?.azubi;
const weitAuseinander = liegtWeitAuseinander(bewertung);
return (
<li key={kriterium.id} data-abweichung={weitAuseinander ? 'ja' : 'nein'}>
<div className="kriterium-kopf">
<strong>{kriterium.titel}</strong>
<span className="leise">{kriterium.erlaeuterung}</span>
</div>
<fieldset className="stufenwahl" disabled={gesperrt}>
<legend className="nur-lesehilfe">
{istAzubi ? 'Deine Selbsteinschätzung' : 'Deine Einschätzung'} für {kriterium.titel}
</legend>
{inhalt.skala.map((stufe) => (
<label key={stufe.wert} className="stufe" title={stufe.text}>
<input
type="radio"
name={`stufe-${kriterium.id}`}
value={stufe.wert}
defaultChecked={eigener === stufe.wert}
/>
<span>{stufe.wert}</span>
</label>
))}
</fieldset>
<div className="kriterium-fuss">
<span className="leise">
{anderer === null || anderer === undefined
? `${istAzubi ? 'Ausbilder' : 'Azubi'}: noch offen`
: `${istAzubi ? 'Ausbilder' : 'Selbsteinschätzung'}: ${anderer} (${
inhalt.skala.find((s) => s.wert === anderer)?.text ?? ''
})`}
</span>
{weitAuseinander ? (
<span className="marke-status" data-ton="warnung">
! unterschiedliche Sicht
</span>
) : null}
</div>
{/* Wer nicht schreiben darf oder kann, sieht die Bemerkung als
Text – und ein leeres Feld gar nicht. Ein abgeschlossenes
Blatt für die Akte soll nicht voller Platzhalter stehen. */}
{istAzubi || gesperrt ? (
bewertung?.bemerkung ? (
<p className="schritt-bemerkung">{bewertung.bemerkung}</p>
) : null
) : (
<input
type="text"
name={`bemerkung-${kriterium.id}`}
maxLength={500}
defaultValue={bewertung?.bemerkung ?? ''}
placeholder="Bemerkung – ein Beispiel sagt mehr als eine Zahl"
/>
)}
</li>
);
})}
</ul>
</section>
{/* --- Freitexte --- */}
{/* Nach Abschluss liest auch der Ausbilder nur noch – ein gesperrtes
Eingabefeld sieht aus, als könnte man es doch noch benutzen. */}
{istAzubi || gesperrt ? (
<section className="stapel-eng" aria-labelledby="texte">
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
<Lesefeld titel="Stärken" text={inhalt.staerken} />
<Lesefeld titel="Entwicklungsfelder" text={inhalt.entwicklungsfelder} />
<Lesefeld titel="Vereinbarte Ziele" text={inhalt.ziele} />
{inhalt.sonstiges ? <Lesefeld titel="Sonstiges" text={inhalt.sonstiges} /> : null}
</section>
) : (
<section className="stapel-eng" aria-labelledby="texte">
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
<div className="feld">
<label htmlFor="staerken">Stärken</label>
<textarea
id="staerken"
name="staerken"
rows={3}
maxLength={4000}
defaultValue={inhalt.staerken}
placeholder="Woran hast du gemerkt, dass er besser geworden ist? Nenne ein Beispiel."
/>
</div>
<div className="feld">
<label htmlFor="entwicklungsfelder">Entwicklungsfelder</label>
<textarea
id="entwicklungsfelder"
name="entwicklungsfelder"
rows={3}
maxLength={4000}
defaultValue={inhalt.entwicklungsfelder}
placeholder="Was soll besser werden – und woran würde man es merken?"
/>
</div>
<div className="feld">
<label htmlFor="ziele">Vereinbarte Ziele bis zum nächsten Gespräch</label>
<textarea
id="ziele"
name="ziele"
rows={3}
maxLength={4000}
defaultValue={inhalt.ziele}
placeholder="So formuliert, dass beim nächsten Mal feststellbar ist, ob sie erreicht wurden."
/>
</div>
<div className="feld">
<label htmlFor="sonstiges">Sonstiges</label>
<textarea
id="sonstiges"
name="sonstiges"
rows={2}
maxLength={4000}
defaultValue={inhalt.sonstiges}
/>
</div>
</section>
)}
{/* --- Leitfaden --- */}
<details className="karte formulierungshilfe" open={!istAzubi && !gesperrt}>
<summary>
<strong>Gesprächsleitfaden</strong>
<span className="leise"> – Fragen für das Gespräch</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
{leitfaden.map((teil, stelle) => (
<section key={teil.abschnitt} className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.2rem' }}>{teil.abschnitt}</h3>
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{teil.fragen.map((frage) => (
<li key={frage}>{frage}</li>
))}
</ul>
{istAzubi || gesperrt ? (
inhalt.leitfadennotizen[stelle]?.notiz ? (
<p className="notiz-anzeige">{inhalt.leitfadennotizen[stelle]!.notiz}</p>
) : null
) : (
<>
<label htmlFor={`leitfaden-${stelle}`} className="nur-lesehilfe">
Notiz zu {teil.abschnitt}
</label>
<textarea
id={`leitfaden-${stelle}`}
name={`leitfaden-${stelle}`}
rows={2}
maxLength={4000}
defaultValue={inhalt.leitfadennotizen[stelle]?.notiz ?? ''}
placeholder="Notiz zum Gespräch"
/>
</>
)}
</section>
))}
</div>
</details>
<Meldung zustand={speicherstand} />
<Meldung zustand={bestaetigungsstand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
{!bereitsBestaetigt ? (
<button type="submit" className="knopf knopf-akzent" formAction={bestaetigen}>
{istAzubi ? 'Gespräch bestätigen' : 'Als Ausbilder bestätigen'}
</button>
) : null}
</div>
) : null}
{!gesperrt && !bereitsBestaetigt ? (
<p className="leise" style={{ marginBottom: 0 }}>
Bestätigen heißt: Du bist mit dem Inhalt einverstanden. Sobald beide bestätigt haben, ist das
Gespräch unveränderlich und gehört in die Ausbildungsakte.
</p>
) : null}
</form>
);
}
function Lesefeld({ titel, text }: { titel: string; text: string }) {
return (
<div>
<span className="wochentag-marke">{titel}</span>
{text.trim() ? (
<p className="notiz-anzeige">{text}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch nichts eingetragen.
</p>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,44 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { inhaltVon } from '@/lib/repos/feedback';
import { feedbackPdf } from '@/lib/pdf/feedback';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { gespraechZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) {
const { gespraechId } = await kontext.params;
const gelesen = await gespraechZumLesen(gespraechId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { gespraech, ausbildung } = gelesen;
const azubi = benutzerNachId(ausbildung.azubiId);
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
const pdf = await feedbackPdf({
datum: alsDatumText(gespraech.datum),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung.beruf,
inhalt: inhaltVon(gespraech, inhalte().feedback),
bestaetigtAzubiAm: gespraech.bestaetigtAzubiAm ? alsZeitpunktText(gespraech.bestaetigtAzubiAm) : null,
bestaetigtAusbilderAm: gespraech.bestaetigtAusbilderAm
? alsZeitpunktText(gespraech.bestaetigtAusbilderAm)
: null,
});
const dateiname = `Feedbackgespraech_${gespraech.datum}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
+180
View File
@@ -0,0 +1,180 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import {
anlegen,
bestaetigen,
bestaetigungZuruecknehmen,
gespraechNachId,
inhaltSpeichern,
inhaltVon,
loeschen,
} from '@/lib/repos/feedback';
import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Holt ein Gespräch samt Rechteprüfung.
*
* Beide Seiten dürfen lesen und – solange nicht beide bestätigt haben –
* schreiben. Das Gespräch gehört ihnen gemeinsam; eine Selbsteinschätzung,
* die der Ausbilder eintragen könnte, wäre keine.
*/
export async function gespraechZumLesen(id: string) {
const benutzer = await erfordereBenutzer();
const gespraech = gespraechNachId(id);
if (!gespraech) return null;
const ausbildung = ausbildungNachId(gespraech.ausbildungId);
if (!ausbildung) return null;
if (!darfAzubiDatenSehen(benutzer, ausbildung.azubiId, ausbildung)) return null;
return { benutzer, gespraech, ausbildung };
}
function aktualisiere(id?: string) {
revalidatePath('/feedback');
if (id) revalidatePath(`/feedback/${id}`);
}
export async function gespraechAnlegen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) return;
const datum = String(formular.get('datum') ?? '').trim();
const gespraech = anlegen(
ausbildung.id,
ausbilder.id,
inhalte().feedback,
datumMuster.test(datum) ? datum : undefined,
);
aktualisiere(gespraech.id);
redirect(`/feedback/${gespraech.id}`);
}
export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
const { benutzer, gespraech } = gelesen;
if (istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
})) {
return { fehler: 'Das Gespräch ist von beiden Seiten bestätigt und damit unveränderlich.' };
}
const vorlage = inhalte().feedback;
const bestand = inhaltVon(gespraech, vorlage);
const istAzubi = benutzer.rolle === 'azubi';
// Jede Seite trägt nur ihre eigene Spalte ein.
const bewertungen: Gespraechsinhalt['bewertungen'] = bestand.bewertungen.map((bewertung) => {
const roh = String(formular.get(`stufe-${bewertung.kriteriumId}`) ?? '').trim();
const gelesen = roh === '' ? null : Number(roh);
const gueltig = gelesen !== null && bestand.skala.some((s) => s.wert === gelesen) ? gelesen : null;
return {
...bewertung,
ausbilder: istAzubi ? bewertung.ausbilder : gueltig,
azubi: istAzubi ? gueltig : bewertung.azubi,
// Die Bemerkung je Kriterium schreibt der Ausbilder.
bemerkung: istAzubi
? bewertung.bemerkung
: String(formular.get(`bemerkung-${bewertung.kriteriumId}`) ?? '').slice(0, 500),
};
});
// Die Freitexte schreibt der Ausbilder; der Azubi liest sie.
const neu: Gespraechsinhalt = istAzubi
? { ...bestand, bewertungen }
: {
...bestand,
bewertungen,
leitfadennotizen: bestand.leitfadennotizen.map((notiz, stelle) => ({
...notiz,
notiz: String(formular.get(`leitfaden-${stelle}`) ?? '').slice(0, 4000),
})),
staerken: String(formular.get('staerken') ?? '').slice(0, 4000),
entwicklungsfelder: String(formular.get('entwicklungsfelder') ?? '').slice(0, 4000),
ziele: String(formular.get('ziele') ?? '').slice(0, 4000),
sonstiges: String(formular.get('sonstiges') ?? '').slice(0, 4000),
};
const datum = String(formular.get('datum') ?? '').trim();
inhaltSpeichern(id, neu, !istAzubi && datumMuster.test(datum) ? datum : undefined);
aktualisiere(id);
return { erfolg: istAzubi ? 'Deine Selbsteinschätzung ist gespeichert.' : 'Gespeichert.' };
}
export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
const { benutzer, gespraech } = gelesen;
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
const schon = rolle === 'azubi' ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
if (schon) return { fehler: 'Du hast bereits bestätigt.' };
bestaetigen(id, rolle);
aktualisiere(id);
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
return {
erfolg: andereHatSchon
? 'Bestätigt. Beide Seiten haben zugestimmt – das Gespräch ist jetzt unveränderlich.'
: 'Bestätigt. Sobald die andere Seite ebenfalls bestätigt, ist das Gespräch unveränderlich.',
};
}
export async function bestaetigungZurueck(formular: FormData): Promise<void> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return;
const { benutzer, gespraech } = gelesen;
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
// Hat die andere Seite schon bestätigt, ist das Gespräch abgeschlossen.
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
if (andereHatSchon) return;
bestaetigungZuruecknehmen(id, rolle);
aktualisiere(id);
}
export async function gespraechVerwerfen(formular: FormData): Promise<void> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return;
const { benutzer, gespraech } = gelesen;
if (benutzer.rolle !== 'ausbilder') return;
// Bestätigtes bleibt. Verworfen wird nur, was noch niemand unterschrieben hat.
if (gespraech.bestaetigtAzubi === 1 || gespraech.bestaetigtAusbilder === 1) return;
loeschen(id);
revalidatePath('/feedback');
redirect('/feedback');
}
+152
View File
@@ -0,0 +1,152 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { gespraecheVon, inhaltVon } from '@/lib/repos/feedback';
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, heute, montagDerWoche } from '@/lib/zeit';
import { gespraechAnlegen } from './aktionen';
export const metadata: Metadata = { title: 'Feedbackgespräche' };
export const dynamic = 'force-dynamic';
export default async function FeedbackSeite() {
const benutzer = await erfordereBenutzer();
const vorlage = inhalte().feedback;
const beteiligte =
benutzer.rolle === 'ausbilder'
? ausbildungenVonAusbilder(benutzer.id).map(({ azubi, ausbildung }) => ({ azubi, ausbildung }))
: (() => {
const ausbildung = ausbildungNachAzubi(benutzer.id);
return ausbildung ? [{ azubi: benutzer, ausbildung }] : [];
})();
return (
<div className="stapel">
<div>
<h1>Feedbackgespräche</h1>
<p className="leise">
Monatlich, mit Leitfaden und Bewertungsbogen. Von beiden Seiten bestätigt und danach unveränderlich –
für die Ausbildungsakte.
</p>
</div>
{beteiligte.length === 0 ? (
<p className="hinweis hinweis-warnung">
{benutzer.rolle === 'ausbilder' ? (
<>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</>
) : (
'Zu deinem Konto ist noch keine Ausbildung hinterlegt.'
)}
</p>
) : (
beteiligte.map(({ azubi, ausbildung }) => {
const gespraeche = gespraecheVon(ausbildung.id);
const letztes = gespraeche[0];
const faellig =
!letztes || montagDerWoche(letztes.datum) < montagDerWoche(vorMonaten(heute(), 1));
return (
<section key={ausbildung.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{benutzer.rolle === 'ausbilder' ? azubi.name : 'Deine Gespräche'}
</h2>
{benutzer.rolle === 'ausbilder' ? (
<form action={gespraechAnlegen}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="datum" value={heute()} />
<button type="submit" className={`knopf ${faellig ? 'knopf-akzent' : 'knopf-zweit'}`}>
Neues Gespräch anlegen
</button>
</form>
) : null}
</div>
{faellig && benutzer.rolle === 'ausbilder' ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
{letztes
? `Das letzte Gespräch war am ${alsDatumText(letztes.datum)} – das ist über einen Monat her.`
: 'Es gab noch kein Feedbackgespräch.'}
</p>
) : null}
{gespraeche.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Gespräch geführt.
</p>
) : (
<ul className="gespraechsliste">
{gespraeche.map((gespraech) => {
const inhalt = inhaltVon(gespraech, vorlage);
const summe = zusammenfassung(inhalt);
const fertig = istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
});
return (
<li key={gespraech.id}>
<Link href={`/feedback/${gespraech.id}`} className="gespraechszeile">
<span className="gespraech-datum">{alsDatumText(gespraech.datum)}</span>
<span className="gespraech-inhalt">
{summe.bewertet} von {summe.gesamt} Kriterien bewertet
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{summe.schnittAusbilder !== null
? `Ausbilder ⌀ ${zahl(summe.schnittAusbilder)}`
: 'noch keine Einschätzung'}
{summe.schnittAzubi !== null ? ` · Selbsteinschätzung ⌀ ${zahl(summe.schnittAzubi)}` : ''}
{summe.abweichungen.length > 0
? ` · ${summe.abweichungen.length} unterschiedliche Sicht${summe.abweichungen.length === 1 ? '' : 'en'}`
: ''}
</span>
</span>
<span className="reihe" style={{ gap: '0.35rem' }}>
<span
className="marke-status"
data-ton={gespraech.bestaetigtAusbilder === 1 ? 'gut' : 'neutral'}
>
{gespraech.bestaetigtAusbilder === 1 ? '✓' : '○'} Ausbilder
</span>
<span
className="marke-status"
data-ton={gespraech.bestaetigtAzubi === 1 ? 'gut' : 'neutral'}
>
{gespraech.bestaetigtAzubi === 1 ? '✓' : '○'} Azubi
</span>
{fertig ? (
<span className="marke-status" data-ton="gut">
🔒 abgeschlossen
</span>
) : null}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function vorMonaten(datum: string, monate: number): string {
const d = new Date(`${datum}T12:00:00Z`);
d.setUTCMonth(d.getUTCMonth() - monate);
return d.toISOString().slice(0, 10);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+4
View File
@@ -14,6 +14,8 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/auswertung', titel: 'Mein Stand' },
{ pfad: '/feedback', titel: 'Feedback' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -26,6 +28,8 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/auswertung', titel: 'Auswertung' },
{ pfad: '/feedback', titel: 'Feedback' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+171
View File
@@ -739,6 +739,13 @@ a.glossarbegriff:hover {
}
.offen-liste li > span:first-of-type { flex: 1; }
/* Steht neben einem Eintrag „bestätigt", darf davor kein offener Kreis
stehen – das Zeichen soll den Zustand sagen, nicht ihm widersprechen. */
.offen-liste li[data-erledigt="ja"]::before {
content: "✓";
color: var(--gut);
}
.wochentage {
list-style: none;
margin: 0;
@@ -1675,3 +1682,167 @@ a.kontrollzeile:hover { background: var(--flaeche-kraeftig); color: var(--text);
@media print {
.kontrolle-kopf { position: static !important; }
}
/* --- Auswertung --- */
.abdeckungsgruppe > summary {
cursor: pointer;
list-style: none;
min-height: 40px;
display: flex;
align-items: center;
gap: 0.4rem;
}
.abdeckungsgruppe > summary::-webkit-details-marker { display: none; }
.abdeckungsgruppe > summary::before { content: "▸"; color: var(--petrol); }
.abdeckungsgruppe[open] > summary::before { content: "▾"; }
.abdeckungsliste { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
.abdeckungsliste li {
display: flex;
align-items: flex-start;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-klein);
background: var(--grund);
border: 1px solid var(--rand-zart);
border-left: 4px solid var(--rand);
}
.abdeckungsliste li[data-stufe="abgedeckt"] { border-left-color: var(--gut); }
.abdeckungsliste li[data-stufe="angefangen"] { border-left-color: var(--bernstein); }
.abdeckungsliste li[data-stufe="ohne_inhalt"] { border-left-color: var(--fehler); background: var(--fehler-flaeche); }
.abdeckung-kuerzel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-klein);
background: var(--flaeche-kraeftig);
font-weight: 700;
font-size: 0.82rem;
flex-shrink: 0;
}
.abdeckung-titel { flex: 1 1 260px; font-weight: 600; line-height: 1.4; }
.nachweisliste { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.3rem; }
.nachweis {
font-size: 0.75rem;
font-weight: 600;
padding: 0.05rem 0.4rem;
border-radius: 3px;
background: var(--flaeche);
color: var(--text-leise);
cursor: help;
}
.nachweis[data-erledigt="ja"] { background: var(--gut-flaeche); color: var(--gut); }
.verlaufsliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
.verlaufsliste li {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.6rem 0.9rem;
}
.verlauf-titel { flex: 1 1 200px; font-weight: 650; }
.verlauf-balken { display: flex; align-items: center; gap: 0.5rem; flex: 0 1 180px; }
.verlauf-balken .balken { flex: 1; }
.note-klein { width: 2rem; height: 2rem; font-size: 1.1rem; }
/* --- Feedbackgespräch --- */
.gespraechsliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
.gespraechszeile {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.7rem 0.9rem;
text-decoration: none;
color: var(--text);
}
.gespraechszeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
.gespraech-inhalt { flex: 1 1 220px; font-weight: 650; }
.gespraech-datum {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.6rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.82rem;
white-space: nowrap;
flex-shrink: 0;
}
.gespraech-datum-gross { font-size: 0.95rem; padding: 0.25rem 0.7rem; }
.kriterienbogen { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
.kriterienbogen > li {
display: grid;
gap: 0.4rem;
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
}
.kriterienbogen > li[data-abweichung="ja"] { border-color: var(--bernstein); background: var(--warnung-flaeche); }
.kriterium-kopf { display: grid; gap: 0.1rem; }
.kriterium-kopf .leise { font-size: 0.85rem; }
.stufenwahl {
display: flex;
gap: 0.35rem;
border: 0;
margin: 0;
padding: 0;
}
.stufenwahl:disabled { opacity: 0.6; }
.stufe {
margin: 0;
cursor: pointer;
font-weight: 400;
}
.stufe input { position: absolute; opacity: 0; width: 0; height: 0; }
.stufe > span {
display: grid;
place-items: center;
width: 2.4rem;
height: 2.4rem;
border-radius: var(--radius-klein);
border: 2px solid var(--rand);
background: var(--grund);
font-weight: 700;
}
.stufe input:checked + span {
border-color: var(--petrol);
background: var(--petrol);
color: var(--text-auf-petrol);
}
.stufe input:focus-visible + span { outline: 3px solid var(--bernstein); outline-offset: 2px; }
.kriterium-fuss {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
@media print {
.abdeckungsgruppe > summary { display: none; }
.abdeckungsgruppe[open] .abdeckungsliste { margin-top: 0; }
.stufe input:checked + span { border-color: #000; }
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import {
berechneAbdeckung,
berechneLernfelder,
bilanz,
istBelastbar,
nachGruppen,
stufeAus,
type Nachweis,
type Position,
} from './abdeckung';
function nachweis(art: Nachweis['art'], erledigt: boolean, id = `${art}-${erledigt}`): Nachweis {
return { art, id, titel: `${art} ${id}`, erledigt };
}
const positionen: Position[] = [
{ id: 'A1', titel: 'Arbeitsaufgaben planen', kurz: 'Planen', gruppe: 'allgemein', verordnung: 'A, Nr. 1' },
{ id: 'B2', titel: 'Netzwerke installieren', kurz: 'Netzwerke', gruppe: 'si', verordnung: 'C, Nr. 2' },
{ id: 'C4', titel: 'Umweltschutz', kurz: 'Umwelt', gruppe: 'integrativ', verordnung: 'F, Nr. 4' },
];
describe('Gewicht eines Nachweises', () => {
it('hält Aufgabe und Checkliste für belastbar, ein Handout für stützend', () => {
expect(istBelastbar('aufgabe')).toBe(true);
expect(istBelastbar('checkliste')).toBe(true);
expect(istBelastbar('handout')).toBe(false);
});
});
describe('Abdeckungsstufe', () => {
it('meldet eine Position ohne jeden Inhalt als Lücke im Lehrgang', () => {
expect(stufeAus([])).toBe('ohne_inhalt');
});
it('meldet geplanten, aber unbearbeiteten Inhalt als offen', () => {
expect(stufeAus([nachweis('handout', false), nachweis('aufgabe', false)])).toBe('offen');
});
it('lässt ein einzelnes gelesenes Handout nicht als vermittelt gelten', () => {
expect(stufeAus([nachweis('handout', true)])).toBe('angefangen');
});
it('lässt zwei gelesene Handouts als vermittelt gelten', () => {
expect(stufeAus([nachweis('handout', true, 'h1'), nachweis('handout', true, 'h2')])).toBe('abgedeckt');
});
it('lässt eine abgenommene Aufgabe allein als vermittelt gelten', () => {
expect(stufeAus([nachweis('aufgabe', true)])).toBe('abgedeckt');
});
it('lässt eine abgeschlossene Checkliste allein als vermittelt gelten', () => {
expect(stufeAus([nachweis('checkliste', true)])).toBe('abgedeckt');
});
it('zählt unerledigte Nachweise nicht mit', () => {
expect(stufeAus([nachweis('aufgabe', false), nachweis('handout', true)])).toBe('angefangen');
});
});
describe('Abdeckung berechnen', () => {
it('ordnet jeder Position ihre Nachweise zu', () => {
const abdeckung = berechneAbdeckung(
positionen,
new Map([
['A1', [nachweis('aufgabe', true), nachweis('handout', true)]],
['B2', [nachweis('handout', false)]],
]),
);
expect(abdeckung[0]!.stufe).toBe('abgedeckt');
expect(abdeckung[0]!.belastbare).toBe(1);
expect(abdeckung[0]!.stuetzende).toBe(1);
expect(abdeckung[0]!.geplant).toBe(2);
expect(abdeckung[1]!.stufe).toBe('offen');
expect(abdeckung[2]!.stufe).toBe('ohne_inhalt');
});
it('behält die Reihenfolge der Positionen bei', () => {
const abdeckung = berechneAbdeckung(positionen, new Map());
expect(abdeckung.map((a) => a.id)).toEqual(['A1', 'B2', 'C4']);
});
it('übernimmt die Fundstelle in der Verordnung – sie muss nachlesbar bleiben', () => {
const abdeckung = berechneAbdeckung(positionen, new Map());
expect(abdeckung[1]!.verordnung).toBe('C, Nr. 2');
});
});
describe('Bilanz', () => {
it('zählt die vier Lagen getrennt', () => {
const abdeckung = berechneAbdeckung(
positionen,
new Map([
['A1', [nachweis('aufgabe', true)]],
['B2', [nachweis('handout', true)]],
]),
);
const summe = bilanz(abdeckung);
expect(summe.gesamt).toBe(3);
expect(summe.abgedeckt).toBe(1);
expect(summe.angefangen).toBe(1);
expect(summe.ohneInhalt).toBe(1);
expect(summe.offen).toBe(0);
expect(summe.prozent).toBe(33);
});
it('rechnet nur Vermitteltes in den Prozentsatz – angefangen zählt nicht', () => {
const abdeckung = berechneAbdeckung(
positionen,
new Map([
['A1', [nachweis('handout', true)]],
['B2', [nachweis('handout', true)]],
['C4', [nachweis('handout', true)]],
]),
);
expect(bilanz(abdeckung).prozent).toBe(0);
});
it('kommt ohne Positionen zurecht', () => {
expect(bilanz([]).prozent).toBe(0);
expect(bilanz([]).gesamt).toBe(0);
});
});
describe('Nach Gruppen', () => {
it('fasst die Abschnitte des Rahmenplans getrennt zusammen', () => {
const abdeckung = berechneAbdeckung(positionen, new Map([['A1', [nachweis('aufgabe', true)]]]));
const gruppen = nachGruppen(abdeckung);
expect(gruppen.map((g) => g.gruppe)).toEqual(['allgemein', 'si', 'integrativ']);
expect(gruppen[0]!.bilanz.abgedeckt).toBe(1);
expect(gruppen[1]!.bilanz.abgedeckt).toBe(0);
});
it('behält die Reihenfolge des ersten Auftretens', () => {
const gedreht = [...positionen].reverse();
expect(nachGruppen(berechneAbdeckung(gedreht, new Map())).map((g) => g.gruppe)).toEqual([
'integrativ',
'si',
'allgemein',
]);
});
});
describe('Lernfelder', () => {
const lernfelder = [
{ id: 'LF1', titel: 'Das Unternehmen beschreiben', jahr: 1, stunden: 40 },
{ id: 'LF3', titel: 'Clients in Netzwerke einbinden', jahr: 1, stunden: 80 },
];
it('bewertet sie nach derselben Regel', () => {
const abdeckung = berechneLernfelder(lernfelder, new Map([['LF3', [nachweis('aufgabe', true)]]]));
expect(abdeckung[0]!.stufe).toBe('ohne_inhalt');
expect(abdeckung[1]!.stufe).toBe('abgedeckt');
expect(abdeckung[1]!.stunden).toBe(80);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Abdeckung des Ausbildungsrahmenplans.
*
* Das ist die Ansicht, die im Zweifel gegenüber der IHK zählt: Welche
* Berufsbildpositionen sind vermittelt, welche noch nicht?
*
* Die entscheidende Unterscheidung, die eine naive Zählung übersieht:
* **Geplant** ist nicht **vermittelt.** Dass ein Handout zu Position B2
* existiert, heißt nicht, dass der Azubi es durchgearbeitet hat. Und eine
* Position, zu der es gar keinen Inhalt gibt, ist eine Lücke im Lehrgang
* selbst – die schlimmste der vier Lagen, weil sie sonst unbemerkt bleibt.
*/
export type Nachweisart = 'handout' | 'aufgabe' | 'checkliste';
export type Nachweis = {
art: Nachweisart;
id: string;
titel: string;
/** Hat der Azubi das nachweislich erledigt? */
erledigt: boolean;
/** Wann, für die Anzeige. */
erledigtAm?: string | null;
};
/**
* Gewicht eines Nachweises.
*
* Eine abgenommene Praxisaufgabe und ein abgeschlossener Checklisten-Durchlauf
* sind **belastbare** Nachweise: Da hat jemand etwas getan und ein anderer hat
* es gesehen. Ein gelesenes Handout ist ein **stützender** Nachweis – es sagt
* aus, dass der Stoff behandelt wurde, aber nicht, dass er angewendet wurde.
*/
export function istBelastbar(art: Nachweisart): boolean {
return art === 'aufgabe' || art === 'checkliste';
}
export type Abdeckungsstufe = 'ohne_inhalt' | 'offen' | 'angefangen' | 'abgedeckt';
export type Positionsabdeckung = {
id: string;
titel: string;
kurz: string;
gruppe: string;
verordnung: string;
stufe: Abdeckungsstufe;
nachweise: Nachweis[];
belastbare: number;
stuetzende: number;
/** Was es zu dieser Position überhaupt gibt – erledigt oder nicht. */
geplant: number;
};
export const stufenText: Record<Abdeckungsstufe, string> = {
ohne_inhalt: 'kein Inhalt hinterlegt',
offen: 'geplant, noch nicht bearbeitet',
angefangen: 'angefangen',
abgedeckt: 'vermittelt',
};
/**
* Die Regel, nach der eine Position als vermittelt gilt.
*
* **Ein belastbarer Nachweis** (abgenommene Aufgabe, abgeschlossene Checkliste)
* **oder zwei stützende** (gelesene Handouts). Ein einzelnes gelesenes Handout
* genügt bewusst nicht – sonst wäre die Abdeckung nach dem ersten Klick voll
* und die Ansicht wertlos.
*
* Die Regel steht hier an einer Stelle, damit sie sich ändern lässt, ohne die
* Anzeige anzufassen – und damit im Zweifel nachlesbar ist, was gezählt wurde.
*/
export function stufeAus(nachweise: Nachweis[]): Abdeckungsstufe {
if (nachweise.length === 0) return 'ohne_inhalt';
const erledigt = nachweise.filter((n) => n.erledigt);
if (erledigt.length === 0) return 'offen';
const belastbare = erledigt.filter((n) => istBelastbar(n.art)).length;
const stuetzende = erledigt.length - belastbare;
if (belastbare >= 1 || stuetzende >= 2) return 'abgedeckt';
return 'angefangen';
}
export type Position = {
id: string;
titel: string;
kurz: string;
gruppe: string;
verordnung: string;
};
export function berechneAbdeckung(
positionen: Position[],
nachweiseJePosition: Map<string, Nachweis[]>,
): Positionsabdeckung[] {
return positionen.map((position) => {
const nachweise = nachweiseJePosition.get(position.id) ?? [];
const erledigt = nachweise.filter((n) => n.erledigt);
return {
...position,
stufe: stufeAus(nachweise),
nachweise,
belastbare: erledigt.filter((n) => istBelastbar(n.art)).length,
stuetzende: erledigt.filter((n) => !istBelastbar(n.art)).length,
geplant: nachweise.length,
};
});
}
export type Bilanz = {
gesamt: number;
abgedeckt: number;
angefangen: number;
offen: number;
ohneInhalt: number;
/** Anteil der vermittelten Positionen an allen. */
prozent: number;
};
export function bilanz(abdeckung: Positionsabdeckung[]): Bilanz {
const zaehle = (stufe: Abdeckungsstufe) => abdeckung.filter((a) => a.stufe === stufe).length;
const gesamt = abdeckung.length;
const abgedeckt = zaehle('abgedeckt');
return {
gesamt,
abgedeckt,
angefangen: zaehle('angefangen'),
offen: zaehle('offen'),
ohneInhalt: zaehle('ohne_inhalt'),
prozent: gesamt === 0 ? 0 : Math.round((abgedeckt / gesamt) * 100),
};
}
/** Nach Gruppe zusammengefasst – Abschnitt A, B und C stehen getrennt im Plan. */
export function nachGruppen(
abdeckung: Positionsabdeckung[],
): { gruppe: string; positionen: Positionsabdeckung[]; bilanz: Bilanz }[] {
const gruppen: string[] = [];
for (const eintrag of abdeckung) {
if (!gruppen.includes(eintrag.gruppe)) gruppen.push(eintrag.gruppe);
}
return gruppen.map((gruppe) => {
const eigene = abdeckung.filter((a) => a.gruppe === gruppe);
return { gruppe, positionen: eigene, bilanz: bilanz(eigene) };
});
}
// --- Lernfelder der Berufsschule -------------------------------------------
export type Lernfeldabdeckung = {
id: string;
titel: string;
jahr: number;
stunden: number;
stufe: Abdeckungsstufe;
nachweise: Nachweis[];
geplant: number;
};
export function berechneLernfelder(
lernfelder: { id: string; titel: string; jahr: number; stunden: number }[],
nachweiseJeLernfeld: Map<string, Nachweis[]>,
): Lernfeldabdeckung[] {
return lernfelder.map((lernfeld) => {
const nachweise = nachweiseJeLernfeld.get(lernfeld.id) ?? [];
return { ...lernfeld, stufe: stufeAus(nachweise), nachweise, geplant: nachweise.length };
});
}
+141
View File
@@ -0,0 +1,141 @@
import { inhalte } from '../inhalte';
import { gelesenSet } from '../repos/fortschritt';
import { staendeVon as aufgabenStaende } from '../repos/aufgaben';
import { laeufeVon } from '../repos/checklisten';
import { uebersicht as kartenUebersicht } from '../repos/karten';
import { berichteVon, fehlendeWochen } from '../repos/berichtsheft';
import { ergebnisVon, staendeVon as kontrollStaende } from '../repos/kontrollen';
import { alsDatumText, alsZeitpunktText } from '../zeit';
import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckung';
/**
* Sammelt aus Inhalten und Fortschritt alles, was die Auswertung braucht.
*
* Hier liegt die Übersetzung: Welcher erledigte Inhalt zählt auf welche
* Berufsbildposition ein? Die Bewertung selbst steckt in ./abdeckung.
*/
export function rahmenplanabdeckung(azubiId: string) {
const alle = inhalte();
const gelesen = gelesenSet(azubiId);
const aufgaben = aufgabenStaende(azubiId);
const laeufe = laeufeVon(azubiId);
const abgeschlosseneListen = new Map(
laeufe.filter((l) => l.abgeschlossenAm).map((l) => [l.checklisteId, l.abgeschlossenAm!]),
);
const jePosition = new Map<string, Nachweis[]>();
const jeLernfeld = new Map<string, Nachweis[]>();
const anhaengen = (karte: Map<string, Nachweis[]>, schluessel: string, nachweis: Nachweis) => {
const bestand = karte.get(schluessel) ?? [];
bestand.push(nachweis);
karte.set(schluessel, bestand);
};
// --- Handouts ---
for (const handout of alle.handouts) {
const nachweis: Nachweis = {
art: 'handout',
id: handout.id,
titel: handout.titel,
erledigt: gelesen.has(handout.id),
};
for (const position of handout.rahmenplan) anhaengen(jePosition, position, nachweis);
anhaengen(jeLernfeld, handout.lernfeld, nachweis);
}
// --- Praxisaufgaben ---
for (const aufgabe of alle.aufgaben) {
const stand = aufgaben.get(aufgabe.id);
const nachweis: Nachweis = {
art: 'aufgabe',
id: aufgabe.id,
titel: aufgabe.titel,
erledigt: stand?.status === 'abgenommen',
erledigtAm: stand?.abgenommenAm ? alsZeitpunktText(stand.abgenommenAm) : null,
};
for (const position of aufgabe.rahmenplan) anhaengen(jePosition, position, nachweis);
anhaengen(jeLernfeld, aufgabe.lernfeld, nachweis);
}
// --- Checklisten ---
for (const liste of alle.checklisten) {
const abgeschlossenAm = abgeschlosseneListen.get(liste.id);
const nachweis: Nachweis = {
art: 'checkliste',
id: liste.id,
titel: liste.titel,
erledigt: Boolean(abgeschlossenAm),
erledigtAm: abgeschlossenAm ? alsZeitpunktText(abgeschlossenAm) : null,
};
for (const position of liste.rahmenplan) anhaengen(jePosition, position, nachweis);
if (liste.lernfeld) anhaengen(jeLernfeld, liste.lernfeld, nachweis);
}
return {
positionen: berechneAbdeckung(alle.positionen, jePosition),
lernfelder: berechneLernfelder(alle.lernfelder, jeLernfeld),
gruppennamen: alle.rahmenplan.gruppen,
};
}
/** Der persönliche Stand des Azubis – was er selbst sehen will. */
export function eigenerStand(azubiId: string, beginn: string | null) {
const alle = inhalte();
const gelesen = gelesenSet(azubiId);
const aufgaben = aufgabenStaende(azubiId);
const karten = kartenUebersicht(azubiId, alle.karten.map((k) => k.id));
const berichte = berichteVon(azubiId);
const kontrollen = kontrollStaende(azubiId);
const ergebnisse = alle.kontrollen
.map((inhalt) => {
const stand = kontrollen.get(inhalt.id);
if (!stand?.korrigiertAm) return null;
const ergebnis = ergebnisVon(stand.id, inhalt);
return {
id: inhalt.id,
titel: inhalt.titel,
woche: inhalt.woche,
prozent: ergebnis.prozent,
note: ergebnis.note,
notentext: ergebnis.notentext,
erreicht: ergebnis.erreicht,
moeglich: ergebnis.moeglich,
korrigiertAm: alsDatumText(stand.korrigiertAm.slice(0, 10)),
};
})
.filter((e) => e !== null)
.sort((a, b) => a.woche - b.woche);
return {
handouts: { erledigt: gelesen.size, gesamt: alle.handouts.length },
karten: { erledigt: karten.gelernt, gesamt: karten.gesamt, faellig: karten.faellig },
aufgaben: {
erledigt: alle.aufgaben.filter((a) => aufgaben.get(a.id)?.status === 'abgenommen').length,
gesamt: alle.aufgaben.length,
offen: alle.aufgaben.filter((a) => {
const status = aufgaben.get(a.id)?.status ?? 'offen';
return status !== 'abgenommen';
}).length,
zurAbnahme: alle.aufgaben.filter((a) => aufgaben.get(a.id)?.status === 'zur_abnahme').length,
},
berichtsheft: {
gesamt: berichte.length,
abgezeichnet: berichte.filter((b) => b.status === 'abgezeichnet').length,
eingereicht: berichte.filter((b) => b.status === 'eingereicht').length,
offen: berichte.filter((b) => b.status === 'entwurf' || b.status === 'zurueckgegeben').length,
fehlendeWochen: beginn ? fehlendeWochen(azubiId, beginn).length : 0,
},
kontrollen: {
gesamt: alle.kontrollen.length,
korrigiert: ergebnisse.length,
ergebnisse,
},
};
}
export type EigenerStand = ReturnType<typeof eigenerStand>;
export type Rahmenplanabdeckung = ReturnType<typeof rahmenplanabdeckung>;
+25
View File
@@ -0,0 +1,25 @@
-- Baustein 9: Feedbackgespräch.
--
-- Ein Gespräch wird von beiden Seiten bestätigt und ist danach unveränderlich –
-- es gehört in die Ausbildungsakte. Deshalb wird der Inhalt als Ganzes
-- gespeichert, einschließlich der verwendeten Vorlage: Ändert der Ausbilder
-- später die Kriterien in content/feedbackgespraech.yaml, bleibt ein bereits
-- geführtes Gespräch genau so, wie es geführt wurde.
CREATE TABLE feedbackgespraech (
id TEXT PRIMARY KEY,
ausbildung_id TEXT NOT NULL REFERENCES ausbildung (id) ON DELETE CASCADE,
datum TEXT NOT NULL,
-- Vollständiger Inhalt als JSON: Kriterien samt Bewertungen beider Seiten,
-- Stärken, Entwicklungsfelder, Ziele, Notizen zum Leitfaden.
inhalt TEXT NOT NULL DEFAULT '{}',
bestaetigt_azubi INTEGER NOT NULL DEFAULT 0 CHECK (bestaetigt_azubi IN (0, 1)),
bestaetigt_azubi_am TEXT,
bestaetigt_ausbilder INTEGER NOT NULL DEFAULT 0 CHECK (bestaetigt_ausbilder IN (0, 1)),
bestaetigt_ausbilder_am TEXT,
angelegt_von TEXT NOT NULL REFERENCES benutzer (id),
angelegt_am TEXT NOT NULL,
geaendert_am TEXT NOT NULL
);
CREATE INDEX feedbackgespraech_ausbildung_idx ON feedbackgespraech (ausbildung_id, datum);
+24
View File
@@ -301,6 +301,28 @@ export const kontrollAntwort = sqliteTable(
(t) => [uniqueIndex('kontroll_antwort_idx').on(t.kontrolleId, t.frageId)],
);
export const feedbackgespraech = sqliteTable(
'feedbackgespraech',
{
id: text('id').primaryKey(),
ausbildungId: text('ausbildung_id')
.notNull()
.references(() => ausbildung.id, { onDelete: 'cascade' }),
datum: text('datum').notNull(),
inhalt: text('inhalt').notNull().default('{}'),
bestaetigtAzubi: integer('bestaetigt_azubi').notNull().default(0),
bestaetigtAzubiAm: text('bestaetigt_azubi_am'),
bestaetigtAusbilder: integer('bestaetigt_ausbilder').notNull().default(0),
bestaetigtAusbilderAm: text('bestaetigt_ausbilder_am'),
angelegtVon: text('angelegt_von')
.notNull()
.references(() => benutzer.id),
angelegtAm: text('angelegt_am').notNull(),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [index('feedbackgespraech_ausbildung_idx').on(t.ausbildungId, t.datum)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -316,6 +338,7 @@ export type Tagesnotiz = typeof tagesnotiz.$inferSelect;
export type Wochenbericht = typeof wochenbericht.$inferSelect;
export type Kontrolle = typeof kontrolle.$inferSelect;
export type KontrollAntwort = typeof kontrollAntwort.$inferSelect;
export type Feedbackgespraech = typeof feedbackgespraech.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = {
@@ -334,4 +357,5 @@ export const alleTabellen = {
wochenbericht,
kontrolle,
kontrollAntwort,
feedbackgespraech,
};
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import { istAbgeschlossen, leererInhalt, leseInhalt, liegtWeitAuseinander, zusammenfassung } from './inhalt';
import type { Feedbackvorlage } from '../inhalte/schema';
const vorlage: Feedbackvorlage = {
leitfaden: [{ abschnitt: 'Rückblick', fragen: ['Was lief gut?'] }],
kriterien: [
{ id: 'selbst', titel: 'Selbstständigkeit', erlaeuterung: '…' },
{ id: 'sorgfalt', titel: 'Sorgfalt', erlaeuterung: '…' },
],
skala: [
{ wert: 1, text: 'sehr gut' },
{ wert: 2, text: 'gut' },
{ wert: 3, text: 'in Ordnung' },
{ wert: 4, text: 'ausbaufähig' },
{ wert: 5, text: 'Nachholbedarf' },
],
hinweis: '',
};
describe('Leerer Inhalt', () => {
it('legt für jedes Kriterium eine Bewertung an', () => {
const inhalt = leererInhalt(vorlage);
expect(inhalt.bewertungen).toHaveLength(2);
expect(inhalt.bewertungen[0]!.ausbilder).toBeNull();
expect(inhalt.bewertungen[0]!.azubi).toBeNull();
});
it('kopiert die Vorlage mit – sie muss beim Gespräch bleiben', () => {
const inhalt = leererInhalt(vorlage);
expect(inhalt.kriterien).toEqual(vorlage.kriterien);
expect(inhalt.skala).toEqual(vorlage.skala);
});
it('legt für jeden Leitfadenabschnitt ein Notizfeld an', () => {
expect(leererInhalt(vorlage).leitfadennotizen).toEqual([{ abschnitt: 'Rückblick', notiz: '' }]);
});
});
describe('Gespeicherten Inhalt lesen', () => {
it('liest einen vollständigen Datensatz', () => {
const gespeichert = JSON.stringify({
...leererInhalt(vorlage),
staerken: 'Arbeitet sehr sauber.',
bewertungen: [
{ kriteriumId: 'selbst', ausbilder: 2, azubi: 3, bemerkung: 'wird besser' },
{ kriteriumId: 'sorgfalt', ausbilder: 1, azubi: 1, bemerkung: '' },
],
});
const inhalt = leseInhalt(gespeichert, vorlage);
expect(inhalt.staerken).toBe('Arbeitet sehr sauber.');
expect(inhalt.bewertungen[0]!.ausbilder).toBe(2);
expect(inhalt.bewertungen[0]!.azubi).toBe(3);
});
it('fällt bei kaputtem JSON auf einen leeren Inhalt zurück', () => {
const inhalt = leseInhalt('das ist kein JSON', vorlage);
expect(inhalt.kriterien).toEqual(vorlage.kriterien);
expect(inhalt.staerken).toBe('');
});
it('wirft Bewertungen außerhalb der Skala weg', () => {
const gespeichert = JSON.stringify({
...leererInhalt(vorlage),
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 99, azubi: -3, bemerkung: '' }],
});
const inhalt = leseInhalt(gespeichert, vorlage);
expect(inhalt.bewertungen[0]!.ausbilder).toBeNull();
expect(inhalt.bewertungen[0]!.azubi).toBeNull();
});
it('ergänzt fehlende Kriterien, statt die Anzeige auseinanderfallen zu lassen', () => {
const gespeichert = JSON.stringify({
...leererInhalt(vorlage),
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 2, azubi: 2, bemerkung: '' }],
});
const inhalt = leseInhalt(gespeichert, vorlage);
expect(inhalt.bewertungen).toHaveLength(2);
expect(inhalt.bewertungen.find((b) => b.kriteriumId === 'sorgfalt')).toBeDefined();
});
it('behält die alte Vorlage eines geführten Gesprächs bei', () => {
// Der Ausbilder hat die Kriterien inzwischen umbenannt – das Gespräch bleibt.
const alt = JSON.stringify({
...leererInhalt(vorlage),
kriterien: [{ id: 'alt', titel: 'Altes Kriterium', erlaeuterung: 'von damals' }],
});
const neueVorlage: Feedbackvorlage = {
...vorlage,
kriterien: [{ id: 'neu', titel: 'Neues Kriterium', erlaeuterung: 'von heute' }],
};
const inhalt = leseInhalt(alt, neueVorlage);
expect(inhalt.kriterien[0]!.titel).toBe('Altes Kriterium');
});
});
describe('Abschluss', () => {
it('gilt erst, wenn beide bestätigt haben', () => {
expect(istAbgeschlossen({ bestaetigtAzubi: false, bestaetigtAusbilder: false })).toBe(false);
expect(istAbgeschlossen({ bestaetigtAzubi: true, bestaetigtAusbilder: false })).toBe(false);
expect(istAbgeschlossen({ bestaetigtAzubi: false, bestaetigtAusbilder: true })).toBe(false);
expect(istAbgeschlossen({ bestaetigtAzubi: true, bestaetigtAusbilder: true })).toBe(true);
});
});
describe('Weit auseinander', () => {
// Die Grenze ist die eigentliche Aussage des Bogens: Eine Stufe Unterschied
// ist Alltag, zwei Stufen sind ein Gesprächsanlass.
it('zieht die Grenze bei zwei Stufen', () => {
expect(liegtWeitAuseinander({ ausbilder: 2, azubi: 3 })).toBe(false);
expect(liegtWeitAuseinander({ ausbilder: 2, azubi: 4 })).toBe(true);
expect(liegtWeitAuseinander({ ausbilder: 5, azubi: 1 })).toBe(true);
});
it('gilt in beide Richtungen gleich', () => {
expect(liegtWeitAuseinander({ ausbilder: 1, azubi: 4 })).toBe(true);
expect(liegtWeitAuseinander({ ausbilder: 4, azubi: 1 })).toBe(true);
});
it('schweigt, solange eine Seite nichts eingetragen hat', () => {
expect(liegtWeitAuseinander({ ausbilder: 1, azubi: null })).toBe(false);
expect(liegtWeitAuseinander({ ausbilder: null, azubi: 5 })).toBe(false);
expect(liegtWeitAuseinander({ ausbilder: null, azubi: null })).toBe(false);
expect(liegtWeitAuseinander(undefined)).toBe(false);
});
});
describe('Zusammenfassung', () => {
it('rechnet die beiden Schnitte getrennt', () => {
const inhalt = {
...leererInhalt(vorlage),
bewertungen: [
{ kriteriumId: 'selbst', ausbilder: 2, azubi: 3, bemerkung: '' },
{ kriteriumId: 'sorgfalt', ausbilder: 1, azubi: 2, bemerkung: '' },
],
};
const summe = zusammenfassung(inhalt);
expect(summe.schnittAusbilder).toBe(1.5);
expect(summe.schnittAzubi).toBe(2.5);
expect(summe.bewertet).toBe(2);
expect(summe.gesamt).toBe(2);
});
it('nennt Kriterien, bei denen beide Seiten deutlich auseinanderliegen', () => {
const inhalt = {
...leererInhalt(vorlage),
bewertungen: [
{ kriteriumId: 'selbst', ausbilder: 1, azubi: 4, bemerkung: '' },
{ kriteriumId: 'sorgfalt', ausbilder: 2, azubi: 3, bemerkung: '' },
],
};
expect(zusammenfassung(inhalt).abweichungen).toEqual(['Selbstständigkeit']);
});
it('meldet keine Abweichung, solange eine Seite nichts eingetragen hat', () => {
const inhalt = {
...leererInhalt(vorlage),
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 1, azubi: null, bemerkung: '' }],
};
expect(zusammenfassung(inhalt).abweichungen).toEqual([]);
});
it('kommt ohne Bewertungen zurecht', () => {
const summe = zusammenfassung(leererInhalt(vorlage));
expect(summe.schnittAusbilder).toBeNull();
expect(summe.bewertet).toBe(0);
});
});
+172
View File
@@ -0,0 +1,172 @@
import type { FeedbackKriterium, Feedbackvorlage } from '../inhalte/schema';
/**
* Der Inhalt eines Feedbackgesprächs.
*
* Wird als Ganzes gespeichert – **einschließlich der verwendeten Vorlage.**
* Ein bestätigtes Gespräch gehört in die Ausbildungsakte und muss in zwei
* Jahren noch dasselbe aussagen, auch wenn der Ausbilder die Kriterien
* inzwischen umformuliert hat.
*/
export type Bewertung = {
kriteriumId: string;
/** Einschätzung des Ausbilders, 1 bis 5. */
ausbilder: number | null;
/** Selbsteinschätzung des Azubis, 1 bis 5. */
azubi: number | null;
bemerkung: string;
};
export type Gespraechsinhalt = {
/** Kopie der Vorlage zum Zeitpunkt des Gesprächs. */
kriterien: FeedbackKriterium[];
skala: { wert: number; text: string }[];
bewertungen: Bewertung[];
/** Freie Notizen zu den Abschnitten des Leitfadens. */
leitfadennotizen: { abschnitt: string; notiz: string }[];
staerken: string;
entwicklungsfelder: string;
ziele: string;
sonstiges: string;
};
export function leererInhalt(vorlage: Feedbackvorlage): Gespraechsinhalt {
return {
kriterien: vorlage.kriterien,
skala: vorlage.skala,
bewertungen: vorlage.kriterien.map((k) => ({
kriteriumId: k.id,
ausbilder: null,
azubi: null,
bemerkung: '',
})),
leitfadennotizen: vorlage.leitfaden.map((a) => ({ abschnitt: a.abschnitt, notiz: '' })),
staerken: '',
entwicklungsfelder: '',
ziele: '',
sonstiges: '',
};
}
/** Liest den gespeicherten Inhalt und härtet ihn gegen Unsinn ab. */
export function leseInhalt(roh: string, ersatz: Feedbackvorlage): Gespraechsinhalt {
let gelesen: unknown;
try {
gelesen = JSON.parse(roh);
} catch {
return leererInhalt(ersatz);
}
if (typeof gelesen !== 'object' || gelesen === null) return leererInhalt(ersatz);
const wert = gelesen as Record<string, unknown>;
const kriterien = Array.isArray(wert.kriterien)
? (wert.kriterien as FeedbackKriterium[]).filter(
(k) => typeof k?.id === 'string' && typeof k?.titel === 'string',
)
: ersatz.kriterien;
const skala = Array.isArray(wert.skala) && wert.skala.length > 0
? (wert.skala as { wert: number; text: string }[])
: ersatz.skala;
const bewertungen = Array.isArray(wert.bewertungen)
? (wert.bewertungen as Record<string, unknown>[]).map((b) => ({
kriteriumId: String(b.kriteriumId ?? ''),
ausbilder: stufe(b.ausbilder, skala),
azubi: stufe(b.azubi, skala),
bemerkung: typeof b.bemerkung === 'string' ? b.bemerkung : '',
}))
: [];
// Fehlende Kriterien ergänzen, damit die Anzeige nicht auseinanderfällt.
for (const kriterium of kriterien) {
if (!bewertungen.some((b) => b.kriteriumId === kriterium.id)) {
bewertungen.push({ kriteriumId: kriterium.id, ausbilder: null, azubi: null, bemerkung: '' });
}
}
return {
kriterien,
skala,
bewertungen,
leitfadennotizen: Array.isArray(wert.leitfadennotizen)
? (wert.leitfadennotizen as Record<string, unknown>[]).map((n) => ({
abschnitt: String(n.abschnitt ?? ''),
notiz: typeof n.notiz === 'string' ? n.notiz : '',
}))
: ersatz.leitfaden.map((a) => ({ abschnitt: a.abschnitt, notiz: '' })),
staerken: text(wert.staerken),
entwicklungsfelder: text(wert.entwicklungsfelder),
ziele: text(wert.ziele),
sonstiges: text(wert.sonstiges),
};
}
function stufe(wert: unknown, skala: { wert: number }[]): number | null {
const zahl = Number(wert);
if (!Number.isFinite(zahl)) return null;
return skala.some((s) => s.wert === zahl) ? zahl : null;
}
function text(wert: unknown): string {
return typeof wert === 'string' ? wert : '';
}
export type Gespraechsstand = {
bestaetigtAzubi: boolean;
bestaetigtAusbilder: boolean;
};
/**
* Ein Gespräch ist abgeschlossen, wenn **beide** bestätigt haben. Danach ist
* es unveränderlich – das ist der Sinn der Bestätigung.
*/
export function istAbgeschlossen(stand: Gespraechsstand): boolean {
return stand.bestaetigtAzubi && stand.bestaetigtAusbilder;
}
/**
* Liegen beide Seiten weit auseinander?
*
* „Weit" heißt: zwei Stufen oder mehr. Eine Stufe Unterschied ist normal und
* sagt nichts; ab zwei Stufen reden zwei Menschen über verschiedene Dinge –
* genau darüber lohnt das Gespräch.
*
* Die Regel steht hier und nur hier: Ansicht, Übersicht und PDF fragen sie ab.
*/
export function liegtWeitAuseinander(bewertung: Pick<Bewertung, 'ausbilder' | 'azubi'> | undefined): boolean {
if (!bewertung || bewertung.ausbilder === null || bewertung.azubi === null) return false;
return Math.abs(bewertung.ausbilder - bewertung.azubi) >= 2;
}
/** Zusammenfassung für die Übersicht. */
export function zusammenfassung(inhalt: Gespraechsinhalt): {
bewertet: number;
gesamt: number;
schnittAusbilder: number | null;
schnittAzubi: number | null;
/** Kriterien, bei denen beide Seiten mehr als eine Stufe auseinanderliegen. */
abweichungen: string[];
} {
const mitAusbilder = inhalt.bewertungen.filter((b) => b.ausbilder !== null);
const mitAzubi = inhalt.bewertungen.filter((b) => b.azubi !== null);
const abweichungen = inhalt.bewertungen
.filter(liegtWeitAuseinander)
.map((b) => inhalt.kriterien.find((k) => k.id === b.kriteriumId)?.titel ?? b.kriteriumId);
return {
bewertet: mitAusbilder.length,
gesamt: inhalt.kriterien.length,
schnittAusbilder: schnitt(mitAusbilder.map((b) => b.ausbilder!)),
schnittAzubi: schnitt(mitAzubi.map((b) => b.azubi!)),
abweichungen,
};
}
function schnitt(werte: number[]): number | null {
if (werte.length === 0) return null;
return Math.round((werte.reduce((a, b) => a + b, 0) / werte.length) * 10) / 10;
}
+18
View File
@@ -82,6 +82,17 @@ karten:
**Antwort:** So.
`;
const FEEDBACK = `leitfaden:
- abschnitt: Rückblick
fragen: [Was lief gut?]
kriterien:
- { id: selbst, titel: Selbstständigkeit, erlaeuterung: Arbeitet eigenständig. }
skala:
- { wert: 1, text: sehr gut }
- { wert: 5, text: Nachholbedarf }
hinweis: Von beiden zu bestätigen.
`;
const GLOSSAR = `- begriff: Subnetzmaske
formen: [Maske]
erklaerung: Trennt Netz und Rechner.
@@ -95,6 +106,7 @@ function baueBeispiel(aenderungen: Record<string, string | null> = {}): string {
const dateien: Record<string, string> = {
'rahmenplan.yaml': RAHMENPLAN,
'glossar.yaml': GLOSSAR,
'feedbackgespraech.yaml': FEEDBACK,
'wochen/01-netzwerk.yaml': WOCHE,
'handouts/N1-subnetting.md': HANDOUT,
};
@@ -220,6 +232,12 @@ describe('Fehlerdarstellung', () => {
expect(fehler!.message).toContain('npm run inhalte:pruefen');
});
it('meldet eine Feedbackvorlage ohne Kriterien', () => {
const ohne = FEEDBACK.replace(/kriterien:\n( - .*\n)+/, 'kriterien: []\n');
const fehler = fehlerVon(baueBeispiel({ 'feedbackgespraech.yaml': ohne }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Bewertungskriterium'))).toBe(true);
});
it('meldet kaputtes YAML mit der Zeile, statt einen Stapelabzug zu werfen', () => {
const kaputt = RAHMENPLAN.replace('lernfelder:', 'lernfelder:\n - [ das ist kein gültiges YAML');
const fehler = fehlerVon(baueBeispiel({ 'rahmenplan.yaml': kaputt }));
+20
View File
@@ -6,6 +6,7 @@ import { abschnitt, inAbschnitte, nurText, type Abschnitt } from './markdown';
import {
aufgabeKopfSchema,
checklisteSchema,
feedbackSchema,
glossarSchema,
handoutKopfSchema,
kontrolleSchema,
@@ -14,6 +15,7 @@ import {
type AufgabeKopf,
type Berufsbildposition,
type Checkliste,
type Feedbackvorlage,
type GlossarEintrag,
type HandoutKopf,
type Kontrolle,
@@ -88,6 +90,8 @@ export type Inhalte = {
glossar: GlossarEintrag[];
glossarNach: Map<string, GlossarEintrag>;
feedback: Feedbackvorlage;
/** Alle Dateien, die eingelesen wurden – für das Neuladen im Entwicklungsbetrieb. */
dateien: string[];
gelesenAm: string;
@@ -332,6 +336,21 @@ export function ladeInhalte(wurzel: string): Inhalte {
}
}
// --- feedbackgespraech.yaml ---------------------------------------------
const feedbackDatei = path.join(wurzel, 'feedbackgespraech.yaml');
dateien.push(feedbackDatei);
const feedbackGelesen = leseYaml(feedbackDatei, zeigeName(feedbackDatei), feedbackSchema, sammler);
if (feedbackGelesen) {
pruefeDoppelte(
feedbackGelesen.werte.kriterien.map((k) => k.id),
feedbackGelesen,
'kriterien',
'Kriterienkennung',
sammler,
);
}
// --- Verweise prüfen ----------------------------------------------------
pruefeVerweise({
sammler,
@@ -398,6 +417,7 @@ export function ladeInhalte(wurzel: string): Inhalte {
kontrolleNach,
glossar,
glossarNach,
feedback: feedbackGelesen!.werte,
dateien,
gelesenAm: new Date().toISOString(),
};
+24
View File
@@ -169,6 +169,28 @@ export const glossarEintragSchema = z.object({
export const glossarSchema = z.array(glossarEintragSchema).min(1, 'Das Glossar ist leer.');
// --- feedbackgespraech.yaml ----------------------------------------------
export const leitfadenAbschnittSchema = z.object({
abschnitt: nichtLeer('Der Name des Abschnitts'),
fragen: z.array(z.string().min(1)).min(1, 'Ein Abschnitt ohne Fragen ergibt keinen Sinn.'),
});
export const feedbackKriteriumSchema = z.object({
id: nichtLeer('Die Kennung des Kriteriums'),
titel: nichtLeer('Der Titel'),
erlaeuterung: nichtLeer('Die Erläuterung'),
});
export const feedbackSchema = z.object({
leitfaden: z.array(leitfadenAbschnittSchema).min(1, 'Der Gesprächsleitfaden ist leer.'),
kriterien: z.array(feedbackKriteriumSchema).min(1, 'Es muss mindestens ein Bewertungskriterium geben.'),
skala: z
.array(z.object({ wert: z.number().int().min(1).max(10), text: nichtLeer('Der Text der Stufe') }))
.min(2, 'Eine Skala braucht mindestens zwei Stufen.'),
hinweis: z.string().default(''),
});
// --- Typen ----------------------------------------------------------------
export type Berufsbildposition = z.infer<typeof berufsbildpositionSchema>;
@@ -184,3 +206,5 @@ export type ChecklistenGruppe = z.infer<typeof checklistenGruppeSchema>;
export type Kontrolle = z.infer<typeof kontrolleSchema>;
export type Kontrollfrage = z.infer<typeof kontrollfrageSchema>;
export type GlossarEintrag = z.infer<typeof glossarEintragSchema>;
export type Feedbackvorlage = z.infer<typeof feedbackSchema>;
export type FeedbackKriterium = z.infer<typeof feedbackKriteriumSchema>;
+228
View File
@@ -0,0 +1,228 @@
import {
abschnitt,
alsPuffer,
angabe,
FARBEN,
fuerPdf,
fusszeilen,
INHALTSBREITE,
kopf,
neuesDokument,
platzPruefen,
SEITE,
unterschriften,
type Dokument,
} from './grundlage';
import { liegtWeitAuseinander, type Gespraechsinhalt } from '../feedback/inhalt';
/**
* Das Feedbackgespräch als PDF für die Ausbildungsakte.
*
* Enthält beide Einschätzungen nebeneinander – die des Ausbilders und die
* Selbsteinschätzung des Azubis. Genau das ist der Wert des Blattes: Wo beide
* auseinanderliegen, lohnt das Gespräch beim nächsten Mal.
*/
export type Gespraechsangaben = {
datum: string;
azubi: string;
ausbilder: string;
beruf: string;
inhalt: Gespraechsinhalt;
bestaetigtAzubiAm: string | null;
bestaetigtAusbilderAm: string | null;
};
export async function feedbackPdf(angaben: Gespraechsangaben): Promise<Buffer> {
const dokument = neuesDokument(`Feedbackgespräch ${angaben.datum}`, angaben.ausbilder);
const { inhalt } = angaben;
kopf(dokument, {
titel: 'Feedbackgespräch',
untertitel: fuerPdf(`${angaben.azubi} · ${angaben.beruf}`),
rechts: fuerPdf(angaben.datum),
});
angabe(dokument, 'Datum', fuerPdf(angaben.datum));
angabe(dokument, 'Auszubildender', fuerPdf(angaben.azubi));
angabe(dokument, 'Ausbilder', fuerPdf(angaben.ausbilder));
// --- Bewertungsbogen ---
abschnitt(dokument, 'Bewertungsbogen', fuerPdf(skalentext(inhalt)));
bewertungstabelle(dokument, inhalt);
// --- Freitexte ---
freitext(dokument, 'Stärken', inhalt.staerken);
freitext(dokument, 'Entwicklungsfelder', inhalt.entwicklungsfelder);
freitext(dokument, 'Vereinbarte Ziele bis zum nächsten Gespräch', inhalt.ziele);
const notizen = inhalt.leitfadennotizen.filter((n) => n.notiz.trim() !== '');
if (notizen.length > 0) {
abschnitt(dokument, 'Notizen zum Gesprächsleitfaden');
for (const notiz of notizen) {
platzPruefen(dokument, 40);
dokument
.font('Helvetica-Bold')
.fontSize(9.5)
.fillColor(FARBEN.leise)
.text(fuerPdf(notiz.abschnitt), SEITE.rand, dokument.y, { width: INHALTSBREITE });
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(FARBEN.text)
.text(fuerPdf(notiz.notiz), SEITE.rand, dokument.y + 1, { width: INHALTSBREITE });
dokument.x = SEITE.rand;
dokument.y += 6;
}
}
if (inhalt.sonstiges.trim()) freitext(dokument, 'Sonstiges', inhalt.sonstiges);
// --- Bestätigungsvermerk ---
if (angaben.bestaetigtAzubiAm && angaben.bestaetigtAusbilderAm) {
platzPruefen(dokument, 40);
dokument.moveDown(0.8);
dokument
.font('Helvetica-Oblique')
.fontSize(8.5)
.fillColor(FARBEN.gut)
.text(
fuerPdf(
`In der Lern-Bar bestätigt: Auszubildender am ${angaben.bestaetigtAzubiAm}, ` +
`Ausbilder am ${angaben.bestaetigtAusbilderAm}. Das Gespräch ist damit unveränderlich.`,
),
SEITE.rand,
dokument.y,
{ width: INHALTSBREITE },
);
dokument.x = SEITE.rand;
}
unterschriften(dokument, ['Datum, Unterschrift Auszubildender', 'Datum, Unterschrift Ausbilder']);
fusszeilen(dokument, fuerPdf(`Feedbackgespräch ${angaben.azubi} - ${angaben.datum} - Lern-Bar`));
return alsPuffer(dokument);
}
function skalentext(inhalt: Gespraechsinhalt): string {
const erste = inhalt.skala[0];
const letzte = inhalt.skala.at(-1);
if (!erste || !letzte) return '';
return `${erste.wert} = ${erste.text} bis ${letzte.wert} = ${letzte.text}`;
}
/** Kriterien mit den beiden Einschätzungen nebeneinander. */
function bewertungstabelle(dokument: Dokument, inhalt: Gespraechsinhalt): void {
const spalteAusbilder = 58;
const spalteAzubi = 58;
const textBreite = INHALTSBREITE - spalteAusbilder - spalteAzubi - 12;
// Kopfzeile
platzPruefen(dokument, 26);
const kopfY = dokument.y;
dokument.rect(SEITE.rand, kopfY, INHALTSBREITE, 16).fillColor(FARBEN.flaeche).fill();
dokument
.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(FARBEN.leise)
.text('Kriterium', SEITE.rand + 6, kopfY + 4, { width: textBreite });
dokument.text('Ausbilder', SEITE.breite - SEITE.rand - spalteAusbilder - spalteAzubi, kopfY + 4, {
width: spalteAusbilder,
align: 'center',
});
dokument.text('Azubi', SEITE.breite - SEITE.rand - spalteAzubi, kopfY + 4, {
width: spalteAzubi,
align: 'center',
});
dokument.x = SEITE.rand;
dokument.y = kopfY + 20;
for (const kriterium of inhalt.kriterien) {
const bewertung = inhalt.bewertungen.find((b) => b.kriteriumId === kriterium.id);
dokument.font('Helvetica').fontSize(10);
const titelHoehe = dokument.heightOfString(fuerPdf(kriterium.titel), { width: textBreite });
const bemerkung = bewertung?.bemerkung.trim() ?? '';
const bemerkungHoehe = bemerkung
? dokument.font('Helvetica-Oblique').fontSize(8.5).heightOfString(fuerPdf(bemerkung), { width: textBreite }) + 2
: 0;
platzPruefen(dokument, titelHoehe + bemerkungHoehe + 10);
const y = dokument.y;
// Wo beide Seiten weit auseinanderliegen, muss das auch auf dem Papier zu
// sehen sein – zwei Zahlen nebeneinander sagen es sonst nur dem, der
// nachrechnet.
const auseinander = liegtWeitAuseinander(bewertung);
if (auseinander) {
dokument
.rect(SEITE.rand, y - 3, 2.5, titelHoehe + bemerkungHoehe + 4)
.fillColor(FARBEN.bernstein)
.fill();
}
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(FARBEN.text)
.text(fuerPdf(kriterium.titel), SEITE.rand + 6, y, { width: textBreite });
if (bemerkung) {
dokument
.font('Helvetica-Oblique')
.fontSize(8.5)
.fillColor(FARBEN.leise)
.text(fuerPdf(bemerkung), SEITE.rand + 6, dokument.y + 1, { width: textBreite });
}
const unten = dokument.y;
dokument
.font('Helvetica-Bold')
.fontSize(11)
.fillColor(auseinander ? FARBEN.warnung : FARBEN.text)
.text(stufe(bewertung?.ausbilder ?? null), SEITE.breite - SEITE.rand - spalteAusbilder - spalteAzubi, y, {
width: spalteAusbilder,
align: 'center',
});
dokument
.font(auseinander ? 'Helvetica-Bold' : 'Helvetica')
.fontSize(11)
.fillColor(auseinander ? FARBEN.warnung : FARBEN.leise)
.text(stufe(bewertung?.azubi ?? null), SEITE.breite - SEITE.rand - spalteAzubi, y, {
width: spalteAzubi,
align: 'center',
});
dokument.x = SEITE.rand;
dokument.y = Math.max(unten, y + titelHoehe) + 5;
dokument
.moveTo(SEITE.rand, dokument.y - 2)
.lineTo(SEITE.breite - SEITE.rand, dokument.y - 2)
.lineWidth(0.4)
.strokeColor(FARBEN.linie)
.stroke();
}
dokument.fillColor(FARBEN.text);
}
function stufe(wert: number | null): string {
return wert === null ? '–' : String(wert);
}
function freitext(dokument: Dokument, titel: string, text: string): void {
abschnitt(dokument, titel);
platzPruefen(dokument, 30);
dokument
.font(text.trim() ? 'Helvetica' : 'Helvetica-Oblique')
.fontSize(10)
.fillColor(text.trim() ? FARBEN.text : FARBEN.leise)
.text(fuerPdf(text.trim() || 'Keine Eintragung.'), SEITE.rand, dokument.y, { width: INHALTSBREITE });
dokument.x = SEITE.rand;
dokument.y += 4;
}
+3
View File
@@ -19,6 +19,9 @@ export const FARBEN = {
linie: '#C8D8DE',
flaeche: '#EDF3F5',
gut: '#1D6F4A',
// Dunkleres Bernstein: das helle Akzentgelb ist auf Weiß nicht lesbar genug,
// sobald es Text trägt statt nur einen Balken.
warnung: '#8A5214',
} as const;
export const SEITE = {
+86
View File
@@ -0,0 +1,86 @@
import crypto from 'node:crypto';
import { desc, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { heute, jetzt } from '../zeit';
import { leererInhalt, leseInhalt, type Gespraechsinhalt } from '../feedback/inhalt';
import type { Feedbackvorlage } from '../inhalte/schema';
import type { Feedbackgespraech } from '../db/schema';
/** Feedbackgespräche. Der Inhalt liegt als JSON, samt kopierter Vorlage. */
export function gespraecheVon(ausbildungId: string): Feedbackgespraech[] {
return datenbank()
.select()
.from(schema.feedbackgespraech)
.where(eq(schema.feedbackgespraech.ausbildungId, ausbildungId))
.orderBy(desc(schema.feedbackgespraech.datum))
.all();
}
export function gespraechNachId(id: string): Feedbackgespraech | null {
return (
datenbank().select().from(schema.feedbackgespraech).where(eq(schema.feedbackgespraech.id, id)).get() ?? null
);
}
export function anlegen(ausbildungId: string, angelegtVon: string, vorlage: Feedbackvorlage, datum = heute()): Feedbackgespraech {
const datensatz: Feedbackgespraech = {
id: crypto.randomUUID(),
ausbildungId,
datum,
inhalt: JSON.stringify(leererInhalt(vorlage)),
bestaetigtAzubi: 0,
bestaetigtAzubiAm: null,
bestaetigtAusbilder: 0,
bestaetigtAusbilderAm: null,
angelegtVon,
angelegtAm: jetzt(),
geaendertAm: jetzt(),
};
datenbank().insert(schema.feedbackgespraech).values(datensatz).run();
return datensatz;
}
export function inhaltVon(gespraech: Feedbackgespraech, ersatz: Feedbackvorlage): Gespraechsinhalt {
return leseInhalt(gespraech.inhalt, ersatz);
}
export function inhaltSpeichern(id: string, inhalt: Gespraechsinhalt, datum?: string): void {
datenbank()
.update(schema.feedbackgespraech)
.set({ inhalt: JSON.stringify(inhalt), ...(datum ? { datum } : {}), geaendertAm: jetzt() })
.where(eq(schema.feedbackgespraech.id, id))
.run();
}
export function bestaetigen(id: string, rolle: 'azubi' | 'ausbilder'): void {
const feld =
rolle === 'azubi'
? { bestaetigtAzubi: 1, bestaetigtAzubiAm: jetzt() }
: { bestaetigtAusbilder: 1, bestaetigtAusbilderAm: jetzt() };
datenbank()
.update(schema.feedbackgespraech)
.set({ ...feld, geaendertAm: jetzt() })
.where(eq(schema.feedbackgespraech.id, id))
.run();
}
/** Bestätigung zurücknehmen – geht nur, solange die andere Seite noch nicht bestätigt hat. */
export function bestaetigungZuruecknehmen(id: string, rolle: 'azubi' | 'ausbilder'): void {
const feld =
rolle === 'azubi'
? { bestaetigtAzubi: 0, bestaetigtAzubiAm: null }
: { bestaetigtAusbilder: 0, bestaetigtAusbilderAm: null };
datenbank()
.update(schema.feedbackgespraech)
.set({ ...feld, geaendertAm: jetzt() })
.where(eq(schema.feedbackgespraech.id, id))
.run();
}
export function loeschen(id: string): void {
datenbank().delete(schema.feedbackgespraech).where(eq(schema.feedbackgespraech.id, id)).run();
}