feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen

Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:

- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
  Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
  dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
  Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
  beim Hineintippen das Formular heran.

<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.

<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.

Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.

Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.

docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:54:56 +02:00
parent bd1c7b801f
commit dcd3a8ce71
30 changed files with 2151 additions and 12 deletions
+173
View File
@@ -0,0 +1,173 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Stammdaten der betreuten Person.
*
* Die Pruefung laeuft IMMER serverseitig - das Formular im Browser ist nur
* Bequemlichkeit. Alle Meldungen sind auf Deutsch und sagen, was zu tun
* ist, nicht was falsch war.
*/
/** Leere Zeichenketten aus einem Formular 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()
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
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.',
})
.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`))
export const AUFGABENKREISE = [
'VERMOEGENSSORGE',
'GESUNDHEITSSORGE',
'AUFENTHALTSBESTIMMUNG',
'WOHNUNGSANGELEGENHEITEN',
'BEHOERDENANGELEGENHEITEN',
'POST_UND_FERNMELDEVERKEHR',
'RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG',
'ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN',
'SONSTIGES',
] as const
export const AUFGABENKREIS_BESCHRIFTUNG: Record<(typeof AUFGABENKREISE)[number], string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Rechts-, Antrags- und Behördenvertretung',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'Sonstiges',
}
export const BetreuteSchema = z.object({
// --- Person ---------------------------------------------------------------
anrede: optionalerText(20),
vorname: z.string().trim().min(1, 'Bitte den Vornamen angeben.').max(100),
nachname: z.string().trim().min(1, 'Bitte den Nachnamen angeben.').max(100),
geburtsname: optionalerText(100),
geburtsdatum: pflichtDatum.refine((datum) => datum <= new Date(), {
message: 'Das Geburtsdatum kann nicht in der Zukunft liegen.',
}),
geburtsort: optionalerText(100),
// --- Heim -----------------------------------------------------------------
heimName: optionalerText(150),
heimStrasse: optionalerText(150),
heimPlz: optionalerText(10),
heimOrt: optionalerText(100),
heimZimmer: optionalerText(50),
heimTelefon: optionalerText(50),
heimEinzugAm: optionalesDatum,
fruehereStrasse: optionalerText(150),
frueherePlz: optionalerText(10),
fruehererOrt: optionalerText(100),
// --- Gesundheit (verschluesselt gespeichert) ------------------------------
krankenkasse: optionalerText(150),
krankenversichertennummer: optionalerText(50),
pflegekasse: optionalerText(150),
pflegegrad: z.coerce
.number()
.int()
.min(1, 'Der Pflegegrad liegt zwischen 1 und 5.')
.max(5, 'Der Pflegegrad liegt zwischen 1 und 5.')
.nullable()
.catch(null),
pflegegradSeit: optionalesDatum,
pflegegradBescheidVom: optionalesDatum,
schwerbehinderungGdB: z.coerce
.number()
.int()
.min(20, 'Ein Grad der Behinderung wird ab 20 festgestellt.')
.max(100, 'Der Grad der Behinderung geht bis 100.')
.nullable()
.catch(null),
schwerbehinderungMerkzeichen: optionalerText(50),
diagnosen: optionalerText(4000),
ermittelterWille: optionalerText(4000),
patientenverfuegungVorhanden: z.coerce.boolean().default(false),
vorsorgevollmachtVorhanden: z.coerce.boolean().default(false),
// --- Betreuungsgericht ----------------------------------------------------
betreuungsgericht: optionalerText(150),
aktenzeichenGericht: optionalerText(80),
bestellungAm: optionalesDatum,
bestellungBis: optionalesDatum,
aufgabenkreise: z.array(z.enum(AUFGABENKREISE)).default([]),
aufgabenkreiseFreitext: optionalerText(500),
befreiterBetreuer: z.coerce.boolean().default(true),
ehrenamtlich: z.coerce.boolean().default(true),
// --- Sozialleistungstraeger ----------------------------------------------
aktenzeichenBezirk: optionalerText(80),
aktenzeichenSozialamt: optionalerText(80),
rentenversicherungsnummer: optionalerText(30),
steuerId: optionalerText(20),
notizen: optionalerText(4000),
})
export type BetreuteEingabe = z.infer<typeof BetreuteSchema>
/**
* Diese Felder wandern verschluesselt in die Datenbank. Der Name ohne
* Suffix ist der Klartext im Formular, mit Suffix der Umschlag.
*/
export const BETREUTE_GEHEIME_FELDER = [
'krankenversichertennummer',
'diagnosen',
'ermittelterWille',
'rentenversicherungsnummer',
'steuerId',
'notizen',
] as const
/**
* Liest ein FormData-Objekt in die Struktur, die das Schema erwartet.
* Kontrollkaestchen liefern "on" oder gar nichts - beides wird hier
* in einen echten Wahrheitswert uebersetzt.
*/
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (name === 'aufgabenkreise') continue
roh[name] = wert
}
roh.aufgabenkreise = formular.getAll('aufgabenkreise').map(String)
roh.patientenverfuegungVorhanden = formular.get('patientenverfuegungVorhanden') === 'on'
roh.vorsorgevollmachtVorhanden = formular.get('vorsorgevollmachtVorhanden') === 'on'
roh.befreiterBetreuer = formular.get('befreiterBetreuer') === 'on'
roh.ehrenamtlich = formular.get('ehrenamtlich') === 'on'
// Leere Zahlenfelder duerfen nicht als "" ins Schema.
for (const feld of ['pflegegrad', 'schwerbehinderungGdB']) {
if (roh[feld] === '') roh[feld] = null
}
return roh
}