Baustein 7: Berichtsheft mit Vorschlaegen, Pruefung, Abzeichnen und PDF

- Taegliche Stichworte, je Tag ein eigenes Formular
- Wochenblatt mit Betrieb, Berufsschule und Sonstiges, je mit Stunden
- Vorschlaege aus Tagesnotizen, Aufgaben, Checklisten und Handouts
- Pruefung mitlaufend beim Tippen: fehlende Stunden, Floskeln,
  IP- und MAC-Adressen, E-Mail, Zugangsdaten, Kundennamen
- Warnung, keine Sperre - Einreichen ist immer moeglich
- Formulierungshilfe mit Taetigkeitsverben und Gegenueberstellungen
- Einreichen, zurueckgeben mit Anmerkung, abzeichnen; danach gesperrt
- Fehlende Wochen werden benannt und lassen sich nachtragen; die laufende
  Nummer richtet sich nach dem Zeitraum, nicht nach dem Anlegen
- PDF im IHK-Layout, einzeln oder als ganzer Zeitraum

Behoben: Im PDF-Kopffeld liefen die Spalten auseinander (dokument.y als
Bezugspunkt in der Schleife). Das Abzeichnen-Formular hatte keine action -
gefunden ueber die ESLint-Meldung zur ungenutzten Serveraktion.

55 neue Tests, insgesamt 241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 23:09:54 +02:00
co-authored by Claude Opus 5
parent b9d6041f3b
commit 7c4a051347
47 changed files with 3679 additions and 8 deletions
@@ -0,0 +1,18 @@
import type { BerichtStatus } from '@/lib/db/schema';
/** Zustand eines Wochenblatts – nie nur über die Farbe, immer mit Zeichen. */
export const berichtsStatusText: Record<BerichtStatus, { text: string; ton: string; zeichen: string }> = {
entwurf: { text: 'Entwurf', ton: 'neutral', zeichen: '○' },
eingereicht: { text: 'eingereicht', ton: 'warnung', zeichen: '▲' },
zurueckgegeben: { text: 'zurückgegeben', ton: 'fehler', zeichen: '↩' },
abgezeichnet: { text: 'abgezeichnet', ton: 'gut', zeichen: '✓' },
};
export function BerichtsMarke({ status }: { status: BerichtStatus }) {
const marke = berichtsStatusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,380 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattEinreichen, blattSpeichern, type Rueckmeldung } from './aktionen';
import { pruefeWochenblatt, summeStunden, type Wochenblatt } from '@/lib/berichtsheft/pruefung';
import type { Vorschlag } from '@/lib/berichtsheft/vorschlaege';
/**
* Das Wochenblatt zum Ausfüllen.
*
* Die Prüfung läuft im Browser mit, während getippt wird – dieselbe Funktion,
* die auch serverseitig verwendet wird. Sie warnt, sie sperrt nicht: Der Knopf
* zum Einreichen bleibt immer bedienbar.
*/
export function Blattformular({
berichtId,
anfangsblatt,
vorschlaege,
status,
}: {
berichtId: string;
anfangsblatt: Wochenblatt;
vorschlaege: Vorschlag[];
status: string;
}) {
const [blatt, setzeBlatt] = useState<Wochenblatt>(() => ({
betrieb: anfangsblatt.betrieb.length > 0 ? anfangsblatt.betrieb : [{ text: '', stunden: 0 }],
schule: anfangsblatt.schule.length > 0 ? anfangsblatt.schule : [{ lernfeld: '', themen: '', stunden: 0 }],
sonstiges: anfangsblatt.sonstiges,
}));
const [uebernommen, setzeUebernommen] = useState<string[]>([]);
const [speicherZustand, speichern] = useActionState<Rueckmeldung, FormData>(blattSpeichern, {});
const [einreichZustand, einreichen] = useActionState<Rueckmeldung, FormData>(blattEinreichen, {});
const gesperrt = status === 'abgezeichnet';
const warnungen = pruefeWochenblatt(blatt);
const ernste = warnungen.filter((w) => w.schwere === 'warnung');
function betriebAendern(stelle: number, teil: Partial<{ text: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
betrieb: v.betrieb.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function schuleAendern(stelle: number, teil: Partial<{ lernfeld: string; themen: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
schule: v.schule.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function uebernehmen(vorschlag: Vorschlag) {
setzeUebernommen((v) => [...v, vorschlag.id]);
if (vorschlag.ziel === 'schule') {
setzeBlatt((v) => {
const leer = v.schule.findIndex((e) => e.lernfeld === '' && e.themen === '');
const neu = { lernfeld: vorschlag.herkunft, themen: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
schule: leer >= 0 ? v.schule.map((e, i) => (i === leer ? neu : e)) : [...v.schule, neu],
};
});
return;
}
setzeBlatt((v) => {
const leer = v.betrieb.findIndex((e) => e.text.trim() === '');
const neu = { text: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
betrieb: leer >= 0 ? v.betrieb.map((e, i) => (i === leer ? neu : e)) : [...v.betrieb, neu],
};
});
}
const offeneVorschlaege = vorschlaege.filter((v) => !uebernommen.includes(v.id));
return (
<div className="stapel">
{!gesperrt && offeneVorschlaege.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="vorschlaege">
<div>
<h2 id="vorschlaege" style={{ marginBottom: '0.15rem', fontSize: '1rem' }}>
Vorschläge aus dieser Woche
</h2>
<p className="leise" style={{ margin: 0 }}>
Übernehmen und dann <strong>in eigene Worte bringen</strong>. Ein wörtlich übernommener Satz ist kein
Ausbildungsnachweis, und die Stundenangabe ist nur ein Anhaltspunkt.
</p>
</div>
<ul className="vorschlagsliste">
{offeneVorschlaege.map((vorschlag) => (
<li key={vorschlag.id}>
<button type="button" className="vorschlag" onClick={() => uebernehmen(vorschlag)}>
<span className="vorschlag-quelle" data-quelle={vorschlag.quelle}>
{beschriftung[vorschlag.quelle]}
</span>
<span className="vorschlag-text">{vorschlag.text}</span>
<span className="leise">
{vorschlag.herkunft}
{vorschlag.stunden > 0 ? ` · ${zahlText(vorschlag.stunden)} h` : ''}
</span>
<span className="vorschlag-plus" aria-hidden="true">
+
</span>
</button>
</li>
))}
</ul>
</section>
) : null}
<form action={speichern} className="stapel" id="blattformular">
<input type="hidden" name="berichtId" value={berichtId} />
{/* --- Betriebliche Tätigkeiten --- */}
<section className="stapel-eng" aria-labelledby="betrieb">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb" style={{ margin: 0 }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, schule: [] }))} h</span>
</div>
{blatt.betrieb.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-text">
<label htmlFor={`betrieb-${stelle}`} className="nur-lesehilfe">
Tätigkeit {stelle + 1}
</label>
<textarea
id={`betrieb-${stelle}`}
name="betriebText"
rows={2}
maxLength={600}
value={eintrag.text}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { text: e.target.value })}
placeholder="Was hast du gemacht? Gerät oder Dienst nennen, Tätigkeit, Ergebnis."
aria-invalid={warnungen.some((w) => w.feld === `betrieb.${stelle}` && w.schwere === 'warnung')}
/>
<Feldwarnungen warnungen={warnungen} feld={`betrieb.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`betrieb-std-${stelle}`}>Std.</label>
<input
id={`betrieb-std-${stelle}`}
name="betriebStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: v.betrieb.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Tätigkeit {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: [...v.betrieb, { text: '', stunden: 0 }] }))}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Berufsschule --- */}
<section className="stapel-eng" aria-labelledby="schule">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule" style={{ margin: 0 }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, betrieb: [] }))} h</span>
</div>
{blatt.schule.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-lernfeld">
<label htmlFor={`lf-${stelle}`}>Lernfeld</label>
<input
id={`lf-${stelle}`}
name="schuleLernfeld"
type="text"
maxLength={10}
value={eintrag.lernfeld}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { lernfeld: e.target.value })}
placeholder="LF3"
/>
</div>
<div className="blattzeile-text">
<label htmlFor={`themen-${stelle}`} className="nur-lesehilfe">
Themen
</label>
<textarea
id={`themen-${stelle}`}
name="schuleThemen"
rows={2}
maxLength={600}
value={eintrag.themen}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { themen: e.target.value })}
placeholder="Themen des Unterrichts"
/>
<Feldwarnungen warnungen={warnungen} feld={`schule.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`schule-std-${stelle}`}>Std.</label>
<input
id={`schule-std-${stelle}`}
name="schuleStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, schule: v.schule.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Schulzeile {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() =>
setzeBlatt((v) => ({ ...v, schule: [...v.schule, { lernfeld: '', themen: '', stunden: 0 }] }))
}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Sonstiges --- */}
<section className="stapel-eng" aria-labelledby="sonstiges">
<h2 id="sonstiges" style={{ margin: 0 }}>
Sonstiges
</h2>
<label htmlFor="sonstiges-feld" className="nur-lesehilfe">
Sonstiges
</label>
<textarea
id="sonstiges-feld"
name="sonstiges"
rows={3}
maxLength={2000}
value={blatt.sonstiges}
disabled={gesperrt}
onChange={(e) => setzeBlatt((v) => ({ ...v, sonstiges: e.target.value }))}
placeholder="Urlaub, Krankheit, Feiertage, Betriebsversammlung – alles, was keine Tätigkeit ist."
/>
<Feldwarnungen warnungen={warnungen} feld="sonstiges" />
</section>
{/* --- Summe und Prüfung --- */}
<section className="karte stapel-eng" aria-labelledby="pruefung">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
<p className="leise" style={{ marginBottom: 0 }}>
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
</p>
</section>
<Meldung zustand={speicherZustand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
</button>
</div>
) : null}
<Meldung zustand={einreichZustand} />
</form>
</div>
);
}
function Feldwarnungen({
warnungen,
feld,
}: {
warnungen: ReturnType<typeof pruefeWochenblatt>;
feld: string;
}) {
const eigene = warnungen.filter((w) => w.feld === feld);
if (eigene.length === 0) return null;
return (
<ul className="feldwarnung">
{eigene.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
{warnung.text}
</li>
))}
</ul>
);
}
const beschriftung: Record<string, string> = {
tagesnotiz: 'Tagesnotiz',
aufgabe: 'Aufgabe',
checkliste: 'Checkliste',
handout: 'Lerninhalt',
};
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,74 @@
import { gegenueberstellungen, regeln, verbgruppen } from '@/lib/berichtsheft/formulierung';
/**
* Formulierungshilfe: Tätigkeitsverben und die Gegenüberstellung
* „so besser nicht / so ist es richtig".
*
* Eingeklappt, weil sie beim zweiten Wochenblatt nur noch stört – aber sie
* bleibt griffbereit, statt in einer Anleitung zu verschwinden.
*/
export function Formulierungshilfe() {
return (
<details className="karte formulierungshilfe nicht-drucken">
<summary>
<strong>Formulierungshilfe</strong>
<span className="leise"> – Tätigkeitsverben und Beispiele</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
<section aria-labelledby="regeln">
<h3 id="regeln" style={{ fontSize: '0.95rem' }}>
Sechs Regeln
</h3>
<ol className="stapel-eng" style={{ margin: 0, paddingLeft: '1.2rem' }}>
{regeln.map((regel, stelle) => (
<li key={stelle}>{regel}</li>
))}
</ol>
</section>
<section aria-labelledby="verben">
<h3 id="verben" style={{ fontSize: '0.95rem' }}>
Tätigkeitsverben
</h3>
<div className="verbgruppen">
{verbgruppen.map((gruppe) => (
<div key={gruppe.titel}>
<span className="wochentag-marke">{gruppe.titel}</span>
<p style={{ margin: 0 }}>{gruppe.verben.join(' · ')}</p>
</div>
))}
</div>
</section>
<section aria-labelledby="beispiele">
<h3 id="beispiele" style={{ fontSize: '0.95rem' }}>
So besser nicht – so ist es richtig
</h3>
<ul className="gegenueberstellung">
{gegenueberstellungen.map((beispiel, stelle) => (
<li key={stelle}>
<p className="schlecht">
<span className="marke-status" data-ton="fehler">
✗ so nicht
</span>{' '}
{beispiel.schlecht}
</p>
<p className="gut">
<span className="marke-status" data-ton="gut">
✓ besser
</span>{' '}
{beispiel.gut}
</p>
<p className="leise" style={{ margin: 0 }}>
{beispiel.warum}
</p>
</li>
))}
</ul>
</section>
</div>
</details>
);
}
@@ -0,0 +1,80 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { tagesnotizSpeichern, type Rueckmeldung } from './aktionen';
export type Tagesfeld = {
datum: string;
wochentag: string;
datumText: string;
text: string;
stunden: number;
istHeute: boolean;
};
/**
* Die täglichen Stichworte.
*
* Der Zweck: zwei Minuten am Tag statt einer Stunde Rekonstruktion am Freitag.
* Deshalb je Tag ein eigenes kleines Formular, das für sich allein absendet –
* ein großes Formular für die ganze Woche würde bei jedem Speichern alles
* neu schreiben und wäre am Handy unbrauchbar.
*/
export function Tagesnotizen({ tage }: { tage: Tagesfeld[] }) {
return (
<div className="tagesnotizen">
{tage.map((tag) => (
<Tagesnotiz key={tag.datum} tag={tag} />
))}
</div>
);
}
function Tagesnotiz({ tag }: { tag: Tagesfeld }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(tagesnotizSpeichern, {});
return (
<form action={aktion} className={`tagesnotiz${tag.istHeute ? ' tagesnotiz-heute' : ''}`}>
<input type="hidden" name="datum" value={tag.datum} />
<div className="tagesnotiz-kopf">
<strong>{tag.wochentag}</strong>
<span className="leise">{tag.datumText}</span>
{tag.istHeute ? (
<span className="marke-status" data-ton="warnung">
● heute
</span>
) : null}
</div>
<label htmlFor={`notiz-${tag.datum}`} className="nur-lesehilfe">
Stichworte für {tag.wochentag}
</label>
<textarea
id={`notiz-${tag.datum}`}
name="text"
rows={2}
maxLength={2000}
defaultValue={tag.text}
placeholder="Stichworte genügen – woran hast du heute gearbeitet?"
/>
<div className="tagesnotiz-fuss">
<label htmlFor={`std-${tag.datum}`}>Stunden</label>
<input
id={`std-${tag.datum}`}
name="stunden"
type="number"
min={0}
max={24}
step={0.25}
defaultValue={tag.stunden || ''}
/>
<Absenden className="knopf knopf-still">Merken</Absenden>
</div>
<Meldung zustand={zustand} />
</form>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattAbzeichnen, blattZurueckgeben, type Rueckmeldung } from '../aktionen';
/**
* Abzeichnen oder zurückgeben.
*
* Zurückgeben verlangt eine Anmerkung – ohne sie weiß der Azubi nicht, was er
* ändern soll, und das Blatt pendelt hin und her.
*/
export function Abzeichnen({
berichtId,
anmerkung,
status,
}: {
berichtId: string;
anmerkung: string;
status: string;
}) {
const [abzeichenZustand, abzeichnen] = useActionState<Rueckmeldung, FormData>(blattAbzeichnen, {});
const [rueckgabeZustand, zurueckgeben] = useActionState<Rueckmeldung, FormData>(blattZurueckgeben, {});
if (status === 'abgezeichnet') return null;
return (
<form action={abzeichnen} className="stapel-eng">
<input type="hidden" name="berichtId" value={berichtId} />
<Meldung zustand={abzeichenZustand} />
<Meldung zustand={rueckgabeZustand} />
<label htmlFor="anmerkung">Anmerkung an den Azubi</label>
<textarea
id="anmerkung"
name="anmerkung"
rows={3}
maxLength={2000}
defaultValue={anmerkung}
placeholder="Beim Abzeichnen freiwillig, beim Zurückgeben nötig: Was soll er ändern?"
/>
<div className="reihe">
<Absenden className="knopf knopf-akzent">Abzeichnen</Absenden>
<button type="submit" className="knopf knopf-zweit" formAction={zurueckgeben}>
Zur Nacharbeit zurückgeben
</button>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abzeichnen sperrt das Blatt endgültig – der Azubi kann es danach nicht mehr ändern.
</p>
</form>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
import { laeufeVon } from '@/lib/repos/checklisten';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { planVon } from '@/lib/repos/wochenplanung';
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
import { Blattformular } from '../Blattformular';
import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
const { berichtId } = await params;
const gelesen = await blattZumLesen(berichtId);
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) notFound();
const { benutzer, bericht } = gelesen;
const eigenes = bericht.benutzerId === benutzer.id;
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
if (!azubi) notFound();
const blatt = alsWochenblatt(bericht);
const warnungen = pruefeWochenblatt(blatt);
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/berichtsheft">Berichtsheft</Link>
{!eigenes ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
<BerichtsMarke status={bericht.status} />
<span className="leise">{kalenderwocheText(bericht.von)}</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
</h1>
<p className="leise">
{azubi.name}
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
</p>
</header>
{bericht.status === 'abgezeichnet' ? (
<p className="hinweis hinweis-gut">
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
Das Blatt ist gesperrt.
</p>
) : null}
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
<div className="hinweis hinweis-warnung">
<strong>Zur Nacharbeit zurückgegeben:</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
<div className="karte">
<strong>Anmerkung des Ausbilders</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{eigenes ? (
<Blattformular
berichtId={bericht.id}
anfangsblatt={blatt}
vorschlaege={vorschlaege}
status={bericht.status}
/>
) : (
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
)}
{/* --- Werkzeuge --- */}
<div className="reihe nicht-drucken">
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Dieses Blatt als PDF
</a>
{eigenes && bericht.status === 'eingereicht' ? (
<form action={blattZurueckziehen}>
<input type="hidden" name="berichtId" value={bericht.id} />
<button type="submit" className="knopf knopf-still">
Einreichung zurückziehen
</button>
</form>
) : null}
</div>
{!eigenes ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
Abzeichnen
</h2>
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
</section>
) : null}
{eigenes ? <Formulierungshilfe /> : null}
</article>
);
}
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
function AnsichtFuerAusbilder({
blatt,
warnungen,
}: {
blatt: ReturnType<typeof alsWochenblatt>;
warnungen: ReturnType<typeof pruefeWochenblatt>;
}) {
return (
<div className="stapel">
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
</div>
{blatt.betrieb.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.betrieb.map((eintrag, stelle) => (
<li key={stelle}>
<span>{eintrag.text}</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
</div>
{blatt.schule.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.schule.map((eintrag, stelle) => (
<li key={stelle}>
<span>
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
{eintrag.themen}
</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
{blatt.sonstiges ? (
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Sonstiges
</h2>
<p className="notiz-anzeige">{blatt.sonstiges}</p>
</section>
) : null}
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
text: n.text,
stunden: n.stunden,
}));
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis;
})
.map((s) => {
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
return {
id: s.aufgabeId,
titel: aufgabe?.titel ?? s.aufgabeId,
zeitrahmen: aufgabe?.zeitrahmen ?? '',
status: s.status,
};
});
const checklisten = laeufeVon(azubiId)
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
const ausbildung = ausbildungNachAzubi(azubiId);
const gelesen = gelesenSet(azubiId);
const wochenImZeitraum = ausbildung
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
: [];
const handouts = wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.filter((id) => gelesen.has(id))
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
const lernfelder = [
...new Set(
wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.map((id) => alle.handoutNach.get(id)?.lernfeld)
.filter((lf): lf is string => Boolean(lf)),
),
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
return baueVorschlaege(rohdaten);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,52 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { bericht } = gelesen;
const azubi = benutzerNachId(bericht.benutzerId);
const ausbildung = ausbildungNachAzubi(bericht.benutzerId);
const ausbilder = ausbildung ? benutzerNachId(ausbildung.ausbilderId) : null;
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
const pdf = await berichtsheftPdf([
{
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: ausbildung ? ausbildungsjahr(ausbildung.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
},
]);
const dateiname = `Ausbildungsnachweis_${String(bericht.nr).padStart(3, '0')}_${bericht.von}.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,197 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import {
abzeichnen,
berichtNachId,
einreichen,
inhaltSpeichern,
notizSetzen,
sorgeFuerBlatt,
zurueckgeben,
} from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/** Zum Bearbeiten: nur das eigene Blatt, und nur solange es nicht abgezeichnet ist. */
async function blattZumBearbeiten(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) throw new Error('Dieses Wochenblatt gibt es nicht.');
if (bericht.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf dieses Wochenblatt.');
return { benutzer, bericht };
}
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) return null;
const bezug = ausbildungVonAzubi(bericht.benutzerId);
if (!darfAzubiDatenSehen(benutzer, bericht.benutzerId, bezug)) return null;
return { benutzer, bericht };
}
function aktualisiere(berichtId?: string) {
revalidatePath('/berichtsheft');
if (berichtId) revalidatePath(`/berichtsheft/${berichtId}`);
revalidatePath('/');
}
// --- Tagesnotizen ----------------------------------------------------------
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Ungültiges Datum.' };
const text = String(formular.get('text') ?? '');
if (text.length > 2000) return { fehler: 'Die Notiz ist zu lang – höchstens 2000 Zeichen.' };
const stunden = zahl(formular.get('stunden'));
if (stunden > 24) return { fehler: 'Mehr als 24 Stunden an einem Tag sind nicht plausibel.' };
notizSetzen(benutzer.id, datum, text, stunden);
aktualisiere();
return { erfolg: text.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}
// --- Wochenblatt -----------------------------------------------------------
export async function blattAnlegen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return;
const bericht = sorgeFuerBlatt(benutzer.id, datum);
aktualisiere(bericht.id);
redirect(`/berichtsheft/${bericht.id}`);
}
/** Liest das Blatt aus dem Formular. Leere Zeilen fliegen raus. */
function leseBlatt(formular: FormData): Wochenblatt {
const betriebTexte = formular.getAll('betriebText').map(String);
const betriebStunden = formular.getAll('betriebStunden');
const schuleLernfelder = formular.getAll('schuleLernfeld').map(String);
const schuleThemen = formular.getAll('schuleThemen').map(String);
const schuleStunden = formular.getAll('schuleStunden');
return {
betrieb: betriebTexte
.map((text, stelle) => ({ text: text.trim(), stunden: zahl(betriebStunden[stelle]) }))
.filter((e) => e.text !== '' || e.stunden > 0),
schule: schuleLernfelder
.map((lernfeld, stelle) => ({
lernfeld: lernfeld.trim(),
themen: (schuleThemen[stelle] ?? '').trim(),
stunden: zahl(schuleStunden[stelle]),
}))
.filter((e) => e.lernfeld !== '' || e.themen !== '' || e.stunden > 0),
sonstiges: String(formular.get('sonstiges') ?? '').trim(),
};
}
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') {
return { fehler: 'Das Blatt ist abgezeichnet und damit gesperrt.' };
}
const blatt = leseBlatt(formular);
if (blatt.betrieb.length > 30) return { fehler: 'Mehr als 30 Tätigkeiten in einer Woche sind zu viel.' };
if (blatt.sonstiges.length > 2000) return { fehler: 'Der Text unter „Sonstiges" ist zu lang.' };
inhaltSpeichern(berichtId, blatt);
aktualisiere(berichtId);
return { erfolg: 'Gespeichert.' };
}
/**
* Einreichen.
*
* Die Prüfung ist eine **Warnung, keine Sperre** – deshalb wird hier nichts
* geprüft, was das Einreichen verhindern könnte. Der Azubi hat die Warnungen
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
*/
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
if (bericht.status === 'eingereicht') return { fehler: 'Das Blatt liegt schon beim Ausbilder.' };
// Vor dem Einreichen den letzten Stand übernehmen, damit nichts verlorengeht.
inhaltSpeichern(berichtId, leseBlatt(formular));
einreichen(berichtId);
aktualisiere(berichtId);
return { erfolg: 'Eingereicht. Dein Ausbilder sieht das Blatt jetzt unter „Was auf dich wartet".' };
}
export async function blattZurueckziehen(formular: FormData): Promise<void> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status !== 'eingereicht') return;
zurueckgeben(berichtId, bericht.anmerkung ?? '');
aktualisiere(berichtId);
}
// --- Abzeichnen durch den Ausbilder ----------------------------------------
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
abzeichnen(berichtId, ausbilder.id, anmerkung || null);
aktualisiere(berichtId);
return { erfolg: 'Abgezeichnet. Das Blatt ist damit gesperrt.' };
}
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (!anmerkung) {
return { fehler: 'Ohne Anmerkung weiß der Azubi nicht, was er ändern soll. Schreib bitte einen Satz dazu.' };
}
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
zurueckgeben(berichtId, anmerkung);
aktualisiere(berichtId);
return { erfolg: 'Zurückgegeben. Der Azubi kann das Blatt jetzt wieder bearbeiten.' };
}
function zahl(wert: FormDataEntryValue | null | undefined): number {
const roh = String(wert ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
return Number.isFinite(gelesen) && gelesen > 0 ? Math.round(gelesen * 100) / 100 : 0;
}
+256
View File
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt, berichteVon, fehlendeWochen, notizenIm } from '@/lib/repos/berichtsheft';
import { summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, heute, kalenderwocheText, montagDerWoche, werktage, wochentagText } from '@/lib/zeit';
import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke';
export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer();
return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} />
) : (
<AzubiSicht azubiId={benutzer.id} />
);
}
// --- Azubi -----------------------------------------------------------------
function AzubiSicht({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const berichte = berichteVon(azubiId);
const heuteDatum = heute();
const tage = werktage(heuteDatum);
const notizen = notizenIm(azubiId, tage[0]!, tage[4]!);
const notizNach = new Map(notizen.map((n) => [n.datum, n]));
const felder: Tagesfeld[] = tage.map((datum) => ({
datum,
wochentag: wochentagText(datum),
datumText: alsDatumText(datum),
text: notizNach.get(datum)?.text ?? '',
stunden: notizNach.get(datum)?.stunden ?? 0,
istHeute: datum === heuteDatum,
}));
const dieseWoche = berichte.find((b) => b.von === montagDerWoche(heuteDatum));
const fehlend = ausbildung ? fehlendeWochen(azubiId, ausbildung.beginn, heuteDatum) : [];
const abgezeichnet = berichte.filter((b) => b.status === 'abgezeichnet').length;
return (
<div className="stapel">
<div>
<h1>Berichtsheft</h1>
<p className="leise">
{berichte.length} {berichte.length === 1 ? 'Wochenblatt' : 'Wochenblätter'} · {abgezeichnet} abgezeichnet
{fehlend.length > 0 ? ` · ${fehlend.length} Wochen ohne Blatt` : ''}
</p>
</div>
{fehlend.length > 0 ? (
<div className="hinweis hinweis-warnung">
<strong>
Für {fehlend.length} vergangene {fehlend.length === 1 ? 'Woche' : 'Wochen'} gibt es noch kein Blatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung – das ist der Teil, der in der
Praxis am häufigsten schiefgeht.
<div className="reihe" style={{ marginTop: '0.6rem' }}>
{fehlend.slice(0, 6).map((montag) => (
<form key={montag} action={blattAnlegen}>
<input type="hidden" name="datum" value={montag} />
<button type="submit" className="knopf knopf-zweit">
{kalenderwocheText(montag)} nachtragen
</button>
</form>
))}
{fehlend.length > 6 ? <span className="leise">… und {fehlend.length - 6} weitere</span> : null}
</div>
</div>
) : null}
<section className="karte-grund stapel" aria-labelledby="notizen">
<div>
<h2 id="notizen" style={{ marginBottom: '0.15rem' }}>
Diese Woche in Stichworten
</h2>
<p className="leise" style={{ margin: 0 }}>
Zwei Minuten am Tag. Freitags baust du daraus mit einem Klick das Wochenblatt – das ist deutlich
weniger Arbeit, als sich am Freitag an den Dienstag zu erinnern.
</p>
</div>
<Tagesnotizen tage={felder} />
</section>
<section className="stapel" aria-labelledby="blaetter">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="blaetter" style={{ margin: 0 }}>
Wochenblätter
</h2>
{dieseWoche ? (
<Link href={`/berichtsheft/${dieseWoche.id}`} className="knopf">
Blatt dieser Woche öffnen
</Link>
) : (
<form action={blattAnlegen}>
<input type="hidden" name="datum" value={heuteDatum} />
<button type="submit" className="knopf knopf-akzent">
Blatt für diese Woche anlegen
</button>
</form>
)}
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt. Leg das erste an, sobald die Woche läuft.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{kalenderwocheText(bericht.von)} · {blatt.betrieb.length} Tätigkeiten ·{' '}
{zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
{berichte.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="export">
<h2 id="export" style={{ marginBottom: 0, fontSize: '1rem' }}>
Ganzen Zeitraum ausgeben
</h2>
<p className="leise" style={{ margin: 0 }}>
Alle Blätter als ein PDF, ein Blatt je Seite – zum Ausdrucken und Abheften.
</p>
<div>
<a
href={`/berichtsheft/pdf?von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alle {berichte.length} Blätter als PDF
</a>
</div>
</section>
) : null}
<Formulierungshilfe />
</div>
);
}
// --- Ausbilder -------------------------------------------------------------
function AusbilderSicht({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return (
<div className="stapel">
<div>
<h1>Berichtshefte</h1>
<p className="leise">Eingereichte Blätter abzeichnen oder zur Nacharbeit zurückgeben.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi, ausbildung }) => {
const berichte = berichteVon(azubi.id);
const offene = berichte.filter((b) => b.status === 'eingereicht');
const fehlend = fehlendeWochen(azubi.id, ausbildung.beginn);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<div className="reihe">
{offene.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {offene.length} zum Abzeichnen
</span>
) : null}
{fehlend.length > 0 ? (
<span className="marke-status" data-ton="fehler">
! {fehlend.length} Wochen ohne Blatt
</span>
) : null}
{berichte.length > 0 ? (
<a
href={`/berichtsheft/pdf?azubi=${azubi.id}&von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alles als PDF
</a>
) : null}
</div>
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{blatt.betrieb.length} Tätigkeiten · {zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,77 @@
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
*
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
*
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
return new Response('Nicht gefunden.', { status: 404 });
}
const von = adresse.searchParams.get('von') ?? '';
const bis = adresse.searchParams.get('bis') ?? '';
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
return new Response('Ungültiger Zeitraum.', { status: 400 });
}
const azubi = benutzerNachId(azubiId);
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
const berichte = berichteVon(azubiId)
.filter((b) => b.bis >= von && b.von <= bis)
.sort((a, b) => a.nr - b.nr);
const blaetter: Blattangaben[] = berichte.map((bericht) => {
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
return {
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
};
});
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.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',
},
});
}
+2
View File
@@ -12,6 +12,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -22,6 +23,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+22 -4
View File
@@ -6,6 +6,7 @@ import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
@@ -148,7 +149,10 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const abnahmen = wartetAufAbnahme(meine.map(({ azubi }) => azubi.id));
const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds);
const berichte = wartetAufAbzeichnung(azubiIds);
const gesamt = abnahmen.length + berichte.length;
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
@@ -156,13 +160,27 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
Was auf dich wartet
</h2>
{abnahmen.length === 0 ? (
{gesamt === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
hier dazu, sobald diese Bausteine stehen.
Nichts offen. Unkorrigierte Lernzielkontrollen kommen hier dazu, sobald dieser Baustein steht.
</p>
) : (
<ul className="offen-liste">
{berichte.map((bericht) => {
const azubi = benutzerNachId(bericht.benutzerId);
return (
<li key={bericht.id}>
<span>
<Link href={`/berichtsheft/${bericht.id}`}>
<strong>Berichtsheft Nr. {bericht.nr}</strong> abzeichnen
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">eingereicht {alsZeitpunktText(bericht.eingereichtAm)}</span>
</li>
);
})}
{abnahmen.map((stand) => {
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
const azubi = benutzerNachId(stand.benutzerId);