Files
betreuungs-cockpit/src/lib/validierung/vorgaenge.ts
T
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00

247 lines
7.4 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()
/** Ein Verweis auf einen anderen Datensatz - leer heisst "keiner". */
const optionaleId = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
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),
ansprechpartnerId: optionaleId,
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
}