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
+250
View File
@@ -0,0 +1,250 @@
import crypto from 'node:crypto';
import { and, asc, desc, eq, gte, lte } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { freitagDerWoche, heute, jetzt, montagDerWoche } from '../zeit';
import type { BerichtStatus, Tagesnotiz, Wochenbericht } from '../db/schema';
import type { Eintrag, Schulwoche, Wochenblatt } from '../berichtsheft/pruefung';
/** Tagesnotizen und Wochenblätter des Ausbildungsnachweises. */
// --- Tagesnotizen ----------------------------------------------------------
export function notizenIm(benutzerId: string, von: string, bis: string): Tagesnotiz[] {
return datenbank()
.select()
.from(schema.tagesnotiz)
.where(
and(
eq(schema.tagesnotiz.benutzerId, benutzerId),
gte(schema.tagesnotiz.datum, von),
lte(schema.tagesnotiz.datum, bis),
),
)
.orderBy(asc(schema.tagesnotiz.datum))
.all();
}
export function notizAm(benutzerId: string, datum: string): Tagesnotiz | null {
return (
datenbank()
.select()
.from(schema.tagesnotiz)
.where(and(eq(schema.tagesnotiz.benutzerId, benutzerId), eq(schema.tagesnotiz.datum, datum)))
.get() ?? null
);
}
/** Schreibt die Notiz eines Tages. Leerer Text mit 0 Stunden löscht sie wieder. */
export function notizSetzen(benutzerId: string, datum: string, text: string, stunden: number): void {
const db = datenbank();
const sauber = text.trim();
if (sauber === '' && stunden <= 0) {
db.delete(schema.tagesnotiz)
.where(and(eq(schema.tagesnotiz.benutzerId, benutzerId), eq(schema.tagesnotiz.datum, datum)))
.run();
return;
}
db.insert(schema.tagesnotiz)
.values({
id: crypto.randomUUID(),
benutzerId,
datum,
text: sauber,
stunden: Math.max(0, stunden),
geaendertAm: jetzt(),
})
.onConflictDoUpdate({
target: [schema.tagesnotiz.benutzerId, schema.tagesnotiz.datum],
set: { text: sauber, stunden: Math.max(0, stunden), geaendertAm: jetzt() },
})
.run();
}
// --- Wochenblätter ---------------------------------------------------------
export function berichteVon(benutzerId: string): Wochenbericht[] {
return datenbank()
.select()
.from(schema.wochenbericht)
.where(eq(schema.wochenbericht.benutzerId, benutzerId))
.orderBy(desc(schema.wochenbericht.nr))
.all();
}
export function berichtNachId(id: string): Wochenbericht | null {
return datenbank().select().from(schema.wochenbericht).where(eq(schema.wochenbericht.id, id)).get() ?? null;
}
export function berichtNachNr(benutzerId: string, nr: number): Wochenbericht | null {
return (
datenbank()
.select()
.from(schema.wochenbericht)
.where(and(eq(schema.wochenbericht.benutzerId, benutzerId), eq(schema.wochenbericht.nr, nr)))
.get() ?? null
);
}
export function berichtImZeitraum(benutzerId: string, von: string): Wochenbericht | null {
return (
datenbank()
.select()
.from(schema.wochenbericht)
.where(and(eq(schema.wochenbericht.benutzerId, benutzerId), eq(schema.wochenbericht.von, von)))
.get() ?? null
);
}
/**
* Legt bei Bedarf das Blatt für die Woche eines Datums an.
*
* Die laufende Nummer ergibt sich aus der Reihenfolge der Zeiträume, nicht aus
* der Reihenfolge des Anlegens: Wer eine Woche nachträgt, soll nicht die
* höchste Nummer bekommen.
*/
export function sorgeFuerBlatt(benutzerId: string, datumInDerWoche: string): Wochenbericht {
const von = montagDerWoche(datumInDerWoche);
const vorhanden = berichtImZeitraum(benutzerId, von);
if (vorhanden) return vorhanden;
const bis = freitagDerWoche(datumInDerWoche);
const alle = berichteVon(benutzerId);
const frueher = alle.filter((b) => b.von < von).length;
const db = datenbank();
// Alles, was zeitlich dahinter liegt, rückt eine Nummer weiter.
for (const bericht of alle.filter((b) => b.von > von)) {
db.update(schema.wochenbericht)
.set({ nr: bericht.nr + 1 })
.where(eq(schema.wochenbericht.id, bericht.id))
.run();
}
const datensatz: Wochenbericht = {
id: crypto.randomUUID(),
benutzerId,
nr: frueher + 1,
von,
bis,
betrieb: '[]',
schule: '[]',
sonstiges: '',
status: 'entwurf',
anmerkung: null,
eingereichtAm: null,
abgezeichnetVon: null,
abgezeichnetAm: null,
geaendertAm: jetzt(),
};
db.insert(schema.wochenbericht).values(datensatz).run();
return datensatz;
}
/** Der Inhalt eines Blattes, aus JSON gelesen und gegen Unsinn abgesichert. */
export function alsWochenblatt(bericht: Wochenbericht): Wochenblatt {
return {
betrieb: leseListe<Eintrag>(bericht.betrieb, (roh) => ({
text: typeof roh.text === 'string' ? roh.text : '',
stunden: typeof roh.stunden === 'number' && Number.isFinite(roh.stunden) ? roh.stunden : 0,
})),
schule: leseListe<Schulwoche>(bericht.schule, (roh) => ({
lernfeld: typeof roh.lernfeld === 'string' ? roh.lernfeld : '',
themen: typeof roh.themen === 'string' ? roh.themen : '',
stunden: typeof roh.stunden === 'number' && Number.isFinite(roh.stunden) ? roh.stunden : 0,
})),
sonstiges: bericht.sonstiges,
};
}
function leseListe<T>(roh: string, abbilden: (wert: Record<string, unknown>) => T): T[] {
try {
const gelesen: unknown = JSON.parse(roh);
if (!Array.isArray(gelesen)) return [];
return gelesen
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
.map(abbilden);
} catch {
return [];
}
}
export function inhaltSpeichern(berichtId: string, blatt: Wochenblatt): void {
datenbank()
.update(schema.wochenbericht)
.set({
betrieb: JSON.stringify(blatt.betrieb),
schule: JSON.stringify(blatt.schule),
sonstiges: blatt.sonstiges,
geaendertAm: jetzt(),
})
.where(eq(schema.wochenbericht.id, berichtId))
.run();
}
export function statusSetzen(berichtId: string, status: BerichtStatus, zusatz: Partial<Wochenbericht> = {}): void {
datenbank()
.update(schema.wochenbericht)
.set({ status, ...zusatz, geaendertAm: jetzt() })
.where(eq(schema.wochenbericht.id, berichtId))
.run();
}
export function einreichen(berichtId: string): void {
statusSetzen(berichtId, 'eingereicht', { eingereichtAm: jetzt(), abgezeichnetAm: null, abgezeichnetVon: null });
}
export function abzeichnen(berichtId: string, ausbilderId: string, anmerkung: string | null): void {
statusSetzen(berichtId, 'abgezeichnet', {
abgezeichnetVon: ausbilderId,
abgezeichnetAm: jetzt(),
anmerkung,
});
}
export function zurueckgeben(berichtId: string, anmerkung: string): void {
statusSetzen(berichtId, 'zurueckgegeben', {
anmerkung,
eingereichtAm: null,
abgezeichnetAm: null,
abgezeichnetVon: null,
});
}
/** Blätter, die auf eine Abzeichnung warten – für „Was wartet auf mich". */
export function wartetAufAbzeichnung(benutzerIds: string[]): Wochenbericht[] {
if (benutzerIds.length === 0) return [];
return datenbank()
.select()
.from(schema.wochenbericht)
.where(eq(schema.wochenbericht.status, 'eingereicht'))
.orderBy(asc(schema.wochenbericht.von))
.all()
.filter((b) => benutzerIds.includes(b.benutzerId));
}
/** Wochen seit Ausbildungsbeginn, für die noch gar kein Blatt existiert. */
export function fehlendeWochen(benutzerId: string, beginn: string, bisDatum = heute()): string[] {
const vorhanden = new Set(berichteVon(benutzerId).map((b) => b.von));
const fehlend: string[] = [];
let montag = montagDerWoche(beginn);
const letzterMontag = montagDerWoche(bisDatum);
// Nur abgelaufene Wochen zählen als fehlend – die laufende noch nicht.
while (montag < letzterMontag) {
if (!vorhanden.has(montag)) fehlend.push(montag);
montag = naechsterMontag(montag);
}
return fehlend;
}
function naechsterMontag(montag: string): string {
const d = new Date(`${montag}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + 7);
return d.toISOString().slice(0, 10);
}