import { z } from 'zod' import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag' /** * Eingabeprüfung für das Sozialhilfemodul. * * Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist - * nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine * Nachforderung erfasst, hat keine Lust auf "invalid input". */ const optionalerText = (max = 200) => z .string() .trim() .max(max, `Bitte höchstens ${max} Zeichen.`) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) const optionalesDatum = z .string() .trim() .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) .refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), { message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', }) .transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`))) const pflichtDatum = z .string() .trim() .min(1, 'Bitte ein Datum angeben.') .refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), { message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', }) .transform((wert) => new Date(`${wert}T00:00:00.000Z`)) /** * Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine * Angabe" und "null Euro" sind fachlich zweierlei. */ const optionalerBetrag = z .string() .trim() .default('') .transform((wert, ctx) => { if (wert === '') return null const cent = betragParsenWennMoeglich(wert) if (cent === null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte einen Betrag wie 1.234,56 eingeben.', }) return z.NEVER } return cent }) /** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */ const betragOderNull = z .string() .trim() .default('') .transform((wert, ctx) => { if (wert === '') return 0n const cent = betragParsenWennMoeglich(wert) if (cent === null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte einen Betrag wie 1.234,56 eingeben.', }) return z.NEVER } if (cent < 0n) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'In dieser Aufstellung stehen nur positive Beträge.', }) return z.NEVER } return cent }) // ----------------------------------------------------------------------------- // Antragsakte // ----------------------------------------------------------------------------- export const AntragSchema = z .object({ traeger: z .string() .trim() .min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.') .max(150), leistungsart: z .string() .trim() .min(2, 'Bitte die Leistungsart angeben.') .max(200), aktenzeichen: optionalerText(80), sachbearbeiter: optionalerText(150), telefon: optionalerText(50), email: z .string() .trim() .max(200) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) .refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), { message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.', }), antragAm: optionalesDatum, eingangBeimTraegerAm: optionalesDatum, bewilligtVon: optionalesDatum, bewilligtBis: optionalesDatum, notizen: optionalerText(4000), }) .refine( (daten) => !daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis, { message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.', path: ['bewilligtBis'], }, ) .refine( (daten) => !daten.antragAm || !daten.eingangBeimTraegerAm || daten.antragAm <= daten.eingangBeimTraegerAm, { message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.', path: ['eingangBeimTraegerAm'], }, ) export type AntragEingabe = z.infer // ----------------------------------------------------------------------------- // Nachforderung // ----------------------------------------------------------------------------- export const NACHFORDERUNG_STATUS_WERTE = [ 'OFFEN', 'IN_BESCHAFFUNG', 'UEBERSANDT', 'ANERKANNT', 'NICHT_VORHANDEN', ] as const export const NachforderungSchema = z .object({ bezeichnung: z .string() .trim() .min(2, 'Bitte benennen, welche Unterlage der Träger will.') .max(200), erlaeuterung: optionalerText(2000), status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'), angefordertAm: optionalesDatum, fristBis: optionalesDatum, uebersandtAm: optionalesDatum, begruendungNichtVorhanden: optionalerText(2000), reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0), dokumentId: optionalerText(40), /** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */ fristAnlegen: z.coerce.boolean().default(false), }) .refine( (daten) => daten.status !== 'NICHT_VORHANDEN' || (daten.begruendungNichtVorhanden !== null && daten.begruendungNichtVorhanden.trim().length >= 5), { message: 'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' + 'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.', path: ['begruendungNichtVorhanden'], }, ) .refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, { message: 'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' + 'Träger später von fehlenden Unterlagen ausgeht.', path: ['uebersandtAm'], }) .refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, { message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.', path: ['fristBis'], }) export type NachforderungEingabe = z.infer export const NachforderungStatusSchema = z .object({ nachforderungId: z.string().trim().min(1), status: z.enum(NACHFORDERUNG_STATUS_WERTE), begruendungNichtVorhanden: optionalerText(2000), uebersandtAm: optionalesDatum, }) .refine( (daten) => daten.status !== 'NICHT_VORHANDEN' || (daten.begruendungNichtVorhanden !== null && daten.begruendungNichtVorhanden.trim().length >= 5), { message: 'Bitte begründen, warum es die Unterlage nicht gibt.', path: ['begruendungNichtVorhanden'], }, ) export const ChecklisteSchema = z.object({ antragId: z.string().trim().min(1), schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'), angefordertAm: optionalesDatum, fristBis: optionalesDatum, }) // ----------------------------------------------------------------------------- // Bescheid // ----------------------------------------------------------------------------- export const BESCHEID_ART_WERTE = [ 'BEWILLIGUNG', 'ABLEHNUNG', 'AENDERUNG', 'AUFHEBUNG', 'ERSTATTUNG', 'VORSCHUSS', 'WIDERSPRUCHSBESCHEID', ] as const export const PRUEFSTATUS_WERTE = [ 'UNGEPRUEFT', 'GEPRUEFT_OK', 'WIDERSPRUCH_EINGELEGT', 'WIDERSPRUCH_ERLEDIGT', 'FRIST_VERSTRICHEN', ] as const export const BescheidSchema = z .object({ art: z.enum(BESCHEID_ART_WERTE), bescheidVom: pflichtDatum, zugegangenAm: optionalesDatum, zeitraumVon: optionalesDatum, zeitraumBis: optionalesDatum, betrag: optionalerBetrag, /** * 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte * Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten. */ rechtsbehelfsfristMonate: z.coerce .number() .int() .refine((wert) => wert === 1 || wert === 12, { message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.', }) .default(1), widerspruchEingelegtAm: optionalesDatum, notizen: optionalerText(4000), dokumentId: optionalerText(40), /** Soll die Frist "Widerspruch möglich bis" angelegt werden? */ fristAnlegen: z.coerce.boolean().default(false), }) .refine( (daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom, { message: 'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' + 'Poststempel.', path: ['zugegangenAm'], }, ) .refine( (daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis, { message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] }, ) .refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, { message: 'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' + 'keine Frist anlegen.', path: ['zugegangenAm'], }) export type BescheidEingabe = z.infer export const BescheidStatusSchema = z.object({ bescheidId: z.string().trim().min(1), pruefStatus: z.enum(PRUEFSTATUS_WERTE), widerspruchEingelegtAm: optionalesDatum, }) // ----------------------------------------------------------------------------- // Vermögensbewertung // ----------------------------------------------------------------------------- export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const export const BewertungSchema = z .object({ herkunft: z.enum(['VERMOEGEN', 'VERTRAG']), id: z.string().trim().min(1), bewertung: z.enum(BEWERTUNG_WERTE), begruendung: optionalerText(6000), }) .refine( (daten) => daten.bewertung === 'UNGEPRUEFT' || (daten.begruendung !== null && daten.begruendung.trim().length >= 10), { message: 'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' + 'Übernimm einen der Textbausteine und passe ihn an.', path: ['begruendung'], }, ) // ----------------------------------------------------------------------------- // Heimkosten // ----------------------------------------------------------------------------- export const HeimkostenSchema = z.object({ jahr: z.coerce .number() .int() .min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.') .max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'), monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12), pflegeEigenanteil: betragOderNull, unterkunftVerpflegung: betragOderNull, investitionskosten: betragOderNull, ausbildungsumlage: betragOderNull, zusatzleistungen: betragOderNull, pflegekasse: betragOderNull, leistungszuschlagProzent: z.coerce .number() .int() .min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .default(0), leistungszuschlag: betragOderNull, rente: betragOderNull, sonstigeEinkuenfte: betragOderNull, sozialhilfe: betragOderNull, eigenmittel: betragOderNull, barbetrag: betragOderNull, bekleidungspauschale: betragOderNull, abwesenheitstage: z.coerce .number() .int() .min(0, 'Abwesenheitstage können nicht negativ sein.') .max(31, 'Ein Monat hat höchstens 31 Tage.') .default(0), bemerkung: optionalerText(2000), }) export type HeimkostenEingabeFormular = z.infer // ----------------------------------------------------------------------------- // Weitere Leistungen // ----------------------------------------------------------------------------- export const LEISTUNG_STATUS_WERTE = [ 'NICHT_GEPRUEFT', 'PRUEFUNG_LAEUFT', 'BEANTRAGT', 'BEWILLIGT', 'ABGELEHNT', 'LAEUFT', 'NICHT_ZUTREFFEND', ] as const export const LeistungSchema = z .object({ leistungId: optionalerText(40), katalogSchluessel: optionalerText(80), bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200), traeger: optionalerText(200), rechtsgrundlage: optionalerText(200), status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'), beantragtAm: optionalesDatum, bewilligtAm: optionalesDatum, gueltigBis: optionalesDatum, betrag: optionalerBetrag, jaehrlichNeuBeantragen: z.coerce.boolean().default(false), notizen: optionalerText(2000), }) .refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, { message: 'Die Bewilligung kann nicht vor dem Antrag liegen.', path: ['bewilligtAm'], }) export const KatalogUebernehmenSchema = z.object({ schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'), }) // ----------------------------------------------------------------------------- // Formularhilfen // ----------------------------------------------------------------------------- /** Kontrollkästchen liefern "on" oder gar nichts. */ export function kaestchen(formular: FormData, name: string): boolean { const wert = formular.get(name) return wert === 'on' || wert === 'true' || wert === '1' } /** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */ export function formularLesen(formular: FormData): Record { const roh: Record = {} for (const [name, wert] of formular.entries()) { if (typeof wert === 'string') roh[name] = wert } return roh } /** * Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten. */ export function feldfehler(fehler: z.ZodError): Record { const flach = fehler.flatten().fieldErrors const ergebnis: Record = {} for (const [feld, meldungen] of Object.entries(flach)) { const erste = meldungen?.[0] if (erste) ergebnis[feld] = erste } return ergebnis }