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:
co-authored by
Claude Opus 5
parent
b9d6041f3b
commit
7c4a051347
@@ -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> – „{warnung.auszug}“</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> – „{warnung.auszug}“</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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user