Files
betreuungs-cockpit/src/lib/validierung/vorgaenge.ts
T
Florian Wilde 22b3a9b9e9 feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
2026-08-22 02:16:22 +02:00

238 lines
7.2 KiB
TypeScript

import { z } from 'zod'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { GENEHMIGUNGSKETTE, SEITENAUSGANG } from '@/lib/vorgaenge/genehmigungskette'
import { VORLAGEN_SCHLUESSEL } from '@/lib/vorgaenge/vorlagen'
/**
* Eingabeprüfung für Vorgänge, Aufgaben und Genehmigungen.
*
* Wie überall gilt: Die Prüfung läuft serverseitig, das Formular im
* Browser ist nur Bequemlichkeit. Meldungen sagen, was zu tun ist.
*/
/** Leere Formularfelder sollen zu null werden, nicht zu "". */
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
// Mitternacht UTC: Faelligkeiten sind @db.Date, ohne Uhrzeit. Sonst
// verschiebt die Zeitzone einen Termin um einen Tag.
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const VORGANG_KATEGORIEN = [
'GERICHT',
'SOZIALHILFE',
'IMMOBILIE',
'VERSICHERUNG',
'HEIM_PFLEGE',
'VERTRAEGE',
'STEUERN',
'SONSTIGES',
] as const satisfies readonly VorgangKategorie[]
export const VORGANG_STATUS = [
'OFFEN',
'IN_ARBEIT',
'WARTET_AUF_DRITTE',
'WARTET_AUF_GENEHMIGUNG',
'ERLEDIGT',
] as const satisfies readonly VorgangStatus[]
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe',
IMMOBILIE: 'Immobilie',
VERSICHERUNG: 'Versicherung',
HEIM_PFLEGE: 'Heim & Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const PRIORITAET_BESCHRIFTUNG: Record<number, string> = {
1: 'Hoch',
2: 'Normal',
3: 'Niedrig',
}
// -----------------------------------------------------------------------------
// Vorgang
// -----------------------------------------------------------------------------
export const VorgangSchema = z.object({
titel: z
.string()
.trim()
.min(3, 'Bitte gib dem Vorgang einen Titel, an dem du ihn wiedererkennst.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
kategorie: z.enum(VORGANG_KATEGORIEN, {
errorMap: () => ({ message: 'Bitte eine Kategorie wählen.' }),
}),
status: z.enum(VORGANG_STATUS).default('OFFEN'),
prioritaet: z.coerce
.number()
.int()
.min(1, 'Die Priorität liegt zwischen 1 (hoch) und 3 (niedrig).')
.max(3, 'Die Priorität liegt zwischen 1 (hoch) und 3 (niedrig).')
.default(2),
beschreibung: optionalerText(4000),
gegenstelle: optionalerText(150),
aktenzeichen: optionalerText(80),
verantwortlich: optionalerText(120),
naechsterSchritt: optionalerText(300),
faelligAm: optionalesDatum,
letzterKontaktAm: optionalesDatum,
genehmigungspflichtig: z.coerce.boolean().default(false),
})
export type VorgangEingabe = z.infer<typeof VorgangSchema>
/**
* Anlegen aus einer Vorlage. Der Schlüssel muss eine bekannte Vorlage sein
* oder fehlen (leerer Vorgang) - erfundene Schlüssel würden sonst still
* einen Vorgang ohne Aufgaben erzeugen.
*/
export const VorgangAnlegenSchema = VorgangSchema.extend({
vorlageSchluessel: z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || VORLAGEN_SCHLUESSEL.includes(wert), {
message: 'Diese Vorlage gibt es nicht.',
}),
})
export type VorgangAnlegenEingabe = z.infer<typeof VorgangAnlegenSchema>
export const VorgangStatusSchema = z.object({
vorgangId: z.string().min(1),
status: z.enum(VORGANG_STATUS, {
errorMap: () => ({ message: 'Diesen Status gibt es nicht.' }),
}),
})
// -----------------------------------------------------------------------------
// Aufgaben
// -----------------------------------------------------------------------------
export const AufgabeSchema = z.object({
vorgangId: z.string().min(1),
titel: z
.string()
.trim()
.min(3, 'Bitte beschreibe den Handgriff in ein paar Worten.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
beschreibung: optionalerText(2000),
faelligAm: optionalesDatum,
})
export const AufgabeAbhakenSchema = z.object({
aufgabeId: z.string().min(1),
erledigt: z.coerce.boolean(),
})
// -----------------------------------------------------------------------------
// Genehmigungen
// -----------------------------------------------------------------------------
export const GENEHMIGUNG_STATUS_WERTE = [...GENEHMIGUNGSKETTE, SEITENAUSGANG] as [
string,
...string[],
]
export const GenehmigungSchema = z.object({
vorgangId: z.string().min(1),
titel: z
.string()
.trim()
.min(3, 'Bitte benenne das Geschäft, das genehmigt werden soll.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
rechtsgrundlage: z
.string()
.trim()
.min(3, 'Bitte die Vorschrift angeben, z. B. „§ 1850 BGB“.')
.max(120, 'Bitte höchstens 120 Zeichen.'),
notizen: optionalerText(4000),
mitteilungAm: optionalesDatum,
antragAm: optionalesDatum,
anhoerungAm: optionalesDatum,
beschlussAm: optionalesDatum,
rechtskraftAm: optionalesDatum,
vollzugAm: optionalesDatum,
})
export type GenehmigungEingabe = z.infer<typeof GenehmigungSchema>
export const GenehmigungStatusSchema = z.object({
genehmigungId: z.string().min(1),
status: z.enum(GENEHMIGUNG_STATUS_WERTE, {
errorMap: () => ({ message: 'Diesen Status gibt es in der Genehmigungskette nicht.' }),
}),
/** Datum der neuen Stufe, sonst wird das heutige gesetzt. */
datum: optionalesDatum,
})
export const GenehmigungBeschlussSchema = z.object({
genehmigungId: z.string().min(1),
/** Leerer Wert löst die Verknüpfung wieder. */
dokumentId: z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable(),
})
// -----------------------------------------------------------------------------
/** Gemeinsamer Rückgabetyp aller Formular-Aktionen (useActionState). */
export type Formularzustand = {
fehler?: string
felder?: Record<string, string>
werte?: Record<string, string>
/** Gesetzt, wenn alles geklappt hat - die Oberfläche kann es melden. */
erfolg?: string
}
/**
* Liest ein FormData-Objekt in die Struktur, die die Schemata erwarten.
* Kontrollkästchen liefern "on" oder gar nichts.
*/
export function formularLesen(
formular: FormData,
kontrollkaestchen: string[] = [],
): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (kontrollkaestchen.includes(name)) continue
roh[name] = wert
}
for (const name of kontrollkaestchen) {
roh[name] = formular.get(name) === 'on' || formular.get(name) === 'true'
}
return roh
}
/** Zod-Fehler in die flache Feldkarte des Formularzustands übersetzen. */
export function felderAusFehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const felder: Record<string, string> = {}
for (const [name, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) felder[name] = erste
}
return felder
}