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:
co-authored by
Claude Opus 5
parent
d226a6bae8
commit
be2a674c7a
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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('.', ',');
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user