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:
@@ -0,0 +1,160 @@
|
|||||||
|
# Konventionen im Betreuungs-Cockpit
|
||||||
|
|
||||||
|
Verbindlich für jeden, der Code in dieses Projekt schreibt. Wer davon
|
||||||
|
abweicht, erzeugt Reibung an der nächsten Schnittstelle.
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
- **Alles auf Deutsch**: Bezeichner, Kommentare, Commit-Nachrichten,
|
||||||
|
Oberflächentexte, Fehlermeldungen. Ausnahmen sind Framework-Vorgaben
|
||||||
|
(`page.tsx`, `layout.tsx`, `default`, `props`) und etablierte
|
||||||
|
Fachbegriffe ohne gute Übersetzung (`Token`, `Hash`, `Cookie`).
|
||||||
|
- Umlaute in **Oberflächentexten**: ja, korrekt geschrieben.
|
||||||
|
Umlaute in **Bezeichnern und Dateinamen**: nein, umschrieben
|
||||||
|
(`vermoegen`, `naechsterSchritt`, `zurueck`).
|
||||||
|
- Du-Ansprache. Keine Juristensprache ohne Erklärung — steht ein
|
||||||
|
Paragraph im Text, gehört eine Erklärung daneben.
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
(auth)/ Anmeldung, Ersteinrichtung, Zweitfaktor
|
||||||
|
(app)/ Alle Fachseiten, hinter Anmeldung + 2FA
|
||||||
|
api/
|
||||||
|
components/
|
||||||
|
ui/ Basisbausteine (Button, Input, Card, …)
|
||||||
|
<bereich>/ Fachkomponenten je Modul
|
||||||
|
lib/
|
||||||
|
crypto/ Feldverschlüsselung, Hashing, TOTP
|
||||||
|
auth/ Sitzung, Rollen, Rate-Limiting
|
||||||
|
validierung/ Zod-Schemata je Entität
|
||||||
|
<bereich>/ Fachlogik ohne React
|
||||||
|
server/
|
||||||
|
actions/ Server Actions ("use server")
|
||||||
|
prisma/
|
||||||
|
tests/ Vitest (Logik)
|
||||||
|
e2e/ Playwright (kritische Flows)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Modul bringt mit: Seite(n) unter `app/(app)/<modul>/`, Fachkomponenten
|
||||||
|
unter `components/<modul>/`, Zod-Schema unter `lib/validierung/<modul>.ts`,
|
||||||
|
Server Actions unter `server/actions/<modul>.ts`, reine Logik unter
|
||||||
|
`lib/<modul>/`.
|
||||||
|
|
||||||
|
## Datenzugriff
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Jede** Abfrage auf Fachdaten filtert `...nichtGeloescht` (Papierkorb).
|
||||||
|
- **Jede** Abfrage ist auf `betreuteId` eingegrenzt.
|
||||||
|
- Zugriff wird immer über `erfordereZugang(betreuteId, modul, art)` geprüft
|
||||||
|
— auch in Server Actions, nicht nur in der Seite. Nie nur im UI ausblenden.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { betreute, benutzer, darfSchreiben } = await erfordereZugang(id, 'FRISTEN', 'schreiben')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geldbeträge
|
||||||
|
|
||||||
|
Immer `BigInt` in **Cent**, Feldname endet auf `Cent`. Anzeige über
|
||||||
|
`formatCent()`. Nie `number` für Beträge, nie `Decimal`, nie Fließkomma.
|
||||||
|
Auf dem Weg zu Client Components über `centZuZahl()`.
|
||||||
|
|
||||||
|
## Datum
|
||||||
|
|
||||||
|
- Fristen, Stichtage, Bescheiddaten: `@db.Date`, ohne Uhrzeit.
|
||||||
|
- Zeitstempel (`erstelltAm`, Protokolle): volles `DateTime`.
|
||||||
|
- Anzeige über `formatDatum()` / `formatDatumZeit()` aus `@/lib/utils`.
|
||||||
|
- Datumsrechnung immer über `date-fns`, nie mit `setDate` von Hand.
|
||||||
|
|
||||||
|
## Verschlüsselte Felder
|
||||||
|
|
||||||
|
Feldname endet auf `Verschluesselt`. Nie direkt schreiben:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { sealen, oeffnen } from '@/lib/crypto/feld'
|
||||||
|
|
||||||
|
const daten = { ...rest, ...sealen('Vorgang', { beschreibung: eingabe.beschreibung }) }
|
||||||
|
const offen = oeffnen('Vorgang', datensatzAusDb) // offen.beschreibung ist Klartext
|
||||||
|
```
|
||||||
|
|
||||||
|
Neues verschlüsseltes Feld? Erst in `VERSCHLUESSELTE_FELDER` in
|
||||||
|
`src/lib/crypto/feld.ts` eintragen, sonst wirft `sealen()`.
|
||||||
|
Verschlüsselte Felder lassen sich **nicht** filtern oder sortieren.
|
||||||
|
|
||||||
|
## Audit-Log
|
||||||
|
|
||||||
|
Jede schreibende Aktion protokolliert:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'GEAENDERT',
|
||||||
|
entitaet: 'Frist',
|
||||||
|
entitaetId: frist.id,
|
||||||
|
bezeichnung: frist.titel,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
aenderung: aenderungErmitteln(vorher, nachher),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Actions
|
||||||
|
|
||||||
|
- Immer `'use server'` oben, immer Zod-Validierung, immer Zugriffsprüfung.
|
||||||
|
- Rückgabe für Formulare: ein Zustandsobjekt
|
||||||
|
`{ fehler?: string; felder?: Record<string, string>; werte?: … }`,
|
||||||
|
benutzt mit `useActionState`. Keine geworfenen Fehler ans Formular.
|
||||||
|
- Nach dem Schreiben `revalidatePath()` auf die betroffene Seite.
|
||||||
|
- Löschen heißt: `geloeschtAm` setzen. Endgültiges Löschen nur im
|
||||||
|
Papierkorb und nur nach `erfordereFrischeAnmeldung()`.
|
||||||
|
|
||||||
|
## Oberfläche
|
||||||
|
|
||||||
|
- Basisbausteine aus `@/components/ui/*`. Formularfelder immer in
|
||||||
|
`<Feld id beschriftung erklaerung fehler>` einwickeln — das verdrahtet
|
||||||
|
Label, Erklärung und Fehler barrierefrei.
|
||||||
|
- Jede Seite beginnt mit `<SeitenKopf titel beschreibung pfad aktionen />`.
|
||||||
|
- Leere Listen bekommen `<Leerzustand>` mit einem konkreten nächsten
|
||||||
|
Schritt, nie nur "keine Daten".
|
||||||
|
- Steht ein Paragraph auf der Seite, steht `<Rechtshinweis>` darunter.
|
||||||
|
- Mindestens 44 px Touchziele, Schrift nie unter 16 px in Eingabefeldern.
|
||||||
|
- Farbe ist nie die einzige Information — Ampeln immer mit Text.
|
||||||
|
- Tabellen scrollen im eigenen Rahmen, nie die Seite.
|
||||||
|
- Keine externen Fonts, keine CDNs, keine Tracker. Bilder als Data-URI
|
||||||
|
oder aus dem eigenen Objektspeicher.
|
||||||
|
|
||||||
|
## Statusfarben (Ampel)
|
||||||
|
|
||||||
|
| Lage | Variante |
|
||||||
|
|---|---|
|
||||||
|
| überfällig | `rot` |
|
||||||
|
| diese Woche fällig | `orange` |
|
||||||
|
| dieser Monat fällig | `gelb` |
|
||||||
|
| erledigt / im Plan | `gruen` |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Fachlogik (Fristberechnung, Beträge, Bewertungen): Vitest unter `tests/`.
|
||||||
|
- Kritische Abläufe: Playwright unter `e2e/`.
|
||||||
|
- Ein Test beschreibt auf Deutsch, was fachlich gelten soll — nicht, welche
|
||||||
|
Funktion aufgerufen wird.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- Deutsch, Imperativ, erste Zeile ≤ 72 Zeichen.
|
||||||
|
- Körper erklärt **warum**, nicht was.
|
||||||
|
- Trailer am Ende, ohne Leerzeile dazwischen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Etappe: 3
|
||||||
|
Phase: Papier
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Trailer wertet `npm run protokoll` aus — daraus entsteht das
|
||||||
|
Bauprotokoll mit den Zeiten je Etappe.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle'
|
||||||
|
import { BenutzerMenue } from '@/components/benutzer-menue'
|
||||||
|
import { BetreuteAuswahl } from '@/components/betreute-auswahl'
|
||||||
|
import { NAV_STRUKTUR, type NavEintrag } from '@/components/navigation'
|
||||||
|
import { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung'
|
||||||
|
import { darf } from '@/lib/auth/rollen'
|
||||||
|
import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen aller Fachseiten.
|
||||||
|
*
|
||||||
|
* Hier laufen die drei Bedingungen zusammen, ohne die niemand an Daten
|
||||||
|
* kommt: angemeldet, zweiter Faktor eingerichtet, mindestens eine betreute
|
||||||
|
* Person zugeordnet.
|
||||||
|
*/
|
||||||
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
await erfordereZweitenFaktor(benutzer)
|
||||||
|
|
||||||
|
const betreute = await meineBetreuten(benutzer)
|
||||||
|
if (betreute.length === 0) {
|
||||||
|
// Erststart: es gibt noch keine Stammdaten.
|
||||||
|
redirect('/ersteinrichtung')
|
||||||
|
}
|
||||||
|
|
||||||
|
const aktive = (await aktiveBetreute(benutzer))!
|
||||||
|
|
||||||
|
// Zugang der aktiven Person bestimmt, welche Menuepunkte sichtbar sind.
|
||||||
|
const eintrag = await db.betreuungsZugang.findUnique({
|
||||||
|
where: { userId_betreuteId: { userId: benutzer.id, betreuteId: aktive.id } },
|
||||||
|
select: { rolle: true, freigegebeneModule: true },
|
||||||
|
})
|
||||||
|
const zugang = eintrag
|
||||||
|
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
|
||||||
|
: benutzer.rolle === 'ADMIN'
|
||||||
|
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Zaehler fuer die Navigation: was ist ueberfaellig, was liegt neu im
|
||||||
|
// Posteingang? Beides beantwortet "wo muss ich hin?" schon im Menue.
|
||||||
|
const heute = new Date()
|
||||||
|
heute.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const [ueberfaellig, neuImPosteingang] = await Promise.all([
|
||||||
|
db.frist.count({
|
||||||
|
where: { betreuteId: aktive.id, status: 'OFFEN', datum: { lt: heute }, ...nichtGeloescht },
|
||||||
|
}),
|
||||||
|
db.dokument.count({
|
||||||
|
where: { betreuteId: aktive.id, posteingangStatus: 'NEU', ...nichtGeloescht },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const navigation: NavEintrag[] = NAV_STRUKTUR.filter((e) => darf(zugang, e.modul, 'lesen')).map(
|
||||||
|
(e) => ({
|
||||||
|
...e,
|
||||||
|
zaehler:
|
||||||
|
e.href === '/fristen' ? ueberfaellig : e.href === '/dokumente' ? neuImPosteingang : undefined,
|
||||||
|
dringend: e.href === '/fristen',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppHuelle
|
||||||
|
navigation={navigation}
|
||||||
|
kopfzeile={
|
||||||
|
<BetreuteAuswahl
|
||||||
|
aktiveId={aktive.id}
|
||||||
|
betreute={betreute.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
vorname: b.vorname,
|
||||||
|
nachname: b.nachname,
|
||||||
|
geburtsdatum: b.geburtsdatum ? b.geburtsdatum.toISOString() : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
benutzerbereich={
|
||||||
|
<BenutzerMenue
|
||||||
|
name={benutzer.name}
|
||||||
|
email={benutzer.email}
|
||||||
|
rolle={ROLLEN_BESCHRIFTUNG[benutzer.rolle]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
schnellerfassung={
|
||||||
|
<SchnellerfassungKnopf
|
||||||
|
eintraege={[
|
||||||
|
{ titel: 'Beleg fotografieren', href: '/dokumente/neu?quelle=kamera' },
|
||||||
|
{ titel: 'Notiz / Kontakt festhalten', href: '/kontakte/protokoll/neu' },
|
||||||
|
{ titel: 'Frist anlegen', href: '/fristen/neu' },
|
||||||
|
{ titel: 'Buchung erfassen', href: '/finanzen/buchungen/neu' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppHuelle>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { useFormStatus } from 'react-dom'
|
||||||
|
import { AlertCircle, Loader2, LogIn } from 'lucide-react'
|
||||||
|
import { anmelden, type AnmeldeZustand } from '@/server/actions/anmelden'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
|
||||||
|
function Absendeknopf() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
|
Wird geprüft …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn aria-hidden="true" />
|
||||||
|
Anmelden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnmeldeFormular({ weiter }: { weiter: string }) {
|
||||||
|
const [zustand, aktion] = useActionState<AnmeldeZustand, FormData>(anmelden, {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="space-y-4" noValidate>
|
||||||
|
<input type="hidden" name="weiter" value={weiter} />
|
||||||
|
|
||||||
|
{zustand.fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Feld id="email" beschriftung="E-Mail-Adresse" fehler={zustand.felder?.email} pflicht>
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
inputMode="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
defaultValue={zustand.email}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld id="passwort" beschriftung="Passwort" fehler={zustand.felder?.passwort} pflicht>
|
||||||
|
<Input name="passwort" type="password" autoComplete="current-password" required />
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="code"
|
||||||
|
beschriftung="Code aus der Authenticator-App"
|
||||||
|
erklaerung="Sechs Ziffern. Alternativ ein Wiederherstellungscode im Format XXXX-XXXX-XXXX."
|
||||||
|
pflicht
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
// Kein maxLength: Wiederherstellungscodes sind laenger.
|
||||||
|
placeholder="123456"
|
||||||
|
className="font-mono tracking-widest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Absendeknopf />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { AnmeldeFormular } from './formular'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Anmelden' }
|
||||||
|
|
||||||
|
/** Fachdaten dürfen nie aus einem Cache kommen. */
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function AnmeldenSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ weiter?: string }>
|
||||||
|
}) {
|
||||||
|
// Wer schon angemeldet ist, soll nicht am Anmeldeformular hängen bleiben.
|
||||||
|
if (await aktuellerBenutzer()) redirect('/heute')
|
||||||
|
|
||||||
|
// Gibt es überhaupt schon einen Zugang? Beim allerersten Start nicht.
|
||||||
|
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
|
||||||
|
if (anzahlBenutzer === 0) redirect('/ersteinrichtung')
|
||||||
|
|
||||||
|
const { weiter = '/heute' } = await searchParams
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Anmelden</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AnmeldeFormular weiter={weiter} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5 rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||||
|
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<p>
|
||||||
|
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
|
||||||
|
deshalb Pflicht und lässt sich nicht abschalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen fuer Anmeldung, Ersteinrichtung und Zweitfaktor.
|
||||||
|
* Bewusst schmal und ruhig - hier gibt es genau eine Aufgabe.
|
||||||
|
*/
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="grid size-10 place-items-center rounded-lg bg-primary font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
BC
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold">Betreuungs-Cockpit</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t px-4 py-4">
|
||||||
|
<div className="mx-auto flex max-w-md flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<Link href="/datenschutz" className="hover:underline">
|
||||||
|
Datenschutz
|
||||||
|
</Link>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>Daten bleiben in der EU</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>Keine Rechtsberatung</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'Betreuungs-Cockpit',
|
||||||
|
template: '%s · Betreuungs-Cockpit',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung: ' +
|
||||||
|
'Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe.',
|
||||||
|
// Kein Indexieren - hier stehen Gesundheits- und Finanzdaten.
|
||||||
|
robots: { index: false, follow: false, nocache: true },
|
||||||
|
applicationName: 'Betreuungs-Cockpit',
|
||||||
|
formatDetection: { telephone: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
// Zoomen bleibt erlaubt. Wer schlecht sieht, muss heranzoomen können.
|
||||||
|
maximumScale: 5,
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#0b1220' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt das Farbschema, BEVOR die Seite gezeichnet wird.
|
||||||
|
*
|
||||||
|
* Ohne dieses Skript blitzt beim Laden kurz das helle Thema auf, auch wenn
|
||||||
|
* dunkel eingestellt ist. Es laeuft synchron im <head> und traegt die
|
||||||
|
* Nonce der Content-Security-Policy - Inline-Skripte ohne Nonce werden
|
||||||
|
* vom Browser blockiert.
|
||||||
|
*/
|
||||||
|
const THEMA_SKRIPT = `
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var gespeichert = localStorage.getItem('bc-thema');
|
||||||
|
var dunkel =
|
||||||
|
gespeichert === 'dunkel' ||
|
||||||
|
((!gespeichert || gespeichert === 'system') &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dunkel);
|
||||||
|
document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light';
|
||||||
|
} catch (e) {
|
||||||
|
/* localStorage kann gesperrt sein - dann bleibt es beim hellen Thema. */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const nonce = (await headers()).get('x-nonce') ?? undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="de" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }} />
|
||||||
|
</head>
|
||||||
|
<body className="min-h-dvh bg-background text-foreground">{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
-12
@@ -1,4 +1,4 @@
|
|||||||
import NextAuth from 'next-auth'
|
import NextAuth, { CredentialsSignin } from 'next-auth'
|
||||||
import Credentials from 'next-auth/providers/credentials'
|
import Credentials from 'next-auth/providers/credentials'
|
||||||
import type { Rolle } from '@prisma/client'
|
import type { Rolle } from '@prisma/client'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
@@ -25,18 +25,23 @@ import { protokollieren } from '@/lib/audit'
|
|||||||
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
|
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class AnmeldeFehler extends Error {
|
export type AnmeldeGrund =
|
||||||
constructor(
|
| 'daten-falsch'
|
||||||
public readonly grund:
|
| 'gesperrt'
|
||||||
| 'daten-falsch'
|
| 'konto-inaktiv'
|
||||||
| 'gesperrt'
|
| 'code-falsch'
|
||||||
| 'konto-inaktiv'
|
|
||||||
| 'zweiter-faktor-fehlt'
|
/**
|
||||||
| 'code-falsch',
|
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
|
||||||
public readonly nochSekunden?: number,
|
* Deshalb erbt der Fehler von CredentialsSignin und kodiert eine etwaige
|
||||||
) {
|
* Restsperrzeit in den Code hinein: "gesperrt:120".
|
||||||
|
*/
|
||||||
|
export class AnmeldeFehler extends CredentialsSignin {
|
||||||
|
code: string
|
||||||
|
|
||||||
|
constructor(grund: AnmeldeGrund, nochSekunden?: number) {
|
||||||
super(grund)
|
super(grund)
|
||||||
this.name = 'AnmeldeFehler'
|
this.code = nochSekunden ? `${grund}:${nochSekunden}` : grund
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { Menu, Plus, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Navigation, type NavEintrag } from '@/components/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen der Anwendung.
|
||||||
|
*
|
||||||
|
* Am Schreibtisch: feste Seitenleiste links.
|
||||||
|
* Am Handy: Kopfzeile mit Menuknopf, die Navigation faehrt als Schublade
|
||||||
|
* ueber die Seite. Zusaetzlich ein fester Knopf unten rechts fuer die
|
||||||
|
* Schnellerfassung - Beleg, Notiz, Frist in maximal zwei Klicks.
|
||||||
|
*/
|
||||||
|
export function AppHuelle({
|
||||||
|
navigation,
|
||||||
|
kopfzeile,
|
||||||
|
schnellerfassung,
|
||||||
|
benutzerbereich,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
navigation: NavEintrag[]
|
||||||
|
kopfzeile: React.ReactNode
|
||||||
|
schnellerfassung?: React.ReactNode
|
||||||
|
benutzerbereich: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [menueOffen, setMenueOffen] = React.useState(false)
|
||||||
|
const pfad = usePathname()
|
||||||
|
|
||||||
|
// Beim Seitenwechsel die Schublade schliessen.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMenueOffen(false)
|
||||||
|
}, [pfad])
|
||||||
|
|
||||||
|
// Escape schliesst die Schublade.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!menueOffen) return
|
||||||
|
const beiTaste = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setMenueOffen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', beiTaste)
|
||||||
|
return () => document.removeEventListener('keydown', beiTaste)
|
||||||
|
}, [menueOffen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col lg:flex-row">
|
||||||
|
<a href="#inhalt" className="skip-link no-print">
|
||||||
|
Zum Inhalt springen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* --- Seitenleiste am Schreibtisch --------------------------------- */}
|
||||||
|
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:flex lg:flex-col">
|
||||||
|
<div className="flex h-16 items-center border-b px-5">
|
||||||
|
<Link href="/heute" className="flex items-center gap-2 font-semibold">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="grid size-8 place-items-center rounded-md bg-primary text-sm text-primary-foreground"
|
||||||
|
>
|
||||||
|
BC
|
||||||
|
</span>
|
||||||
|
<span>Betreuungs-Cockpit</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b p-4">{kopfzeile}</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<Navigation eintraege={navigation} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t p-3">{benutzerbereich}</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* --- Kopfzeile am Handy ------------------------------------------- */}
|
||||||
|
<header className="no-print sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-card/95 px-3 backdrop-blur lg:hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMenueOffen(true)}
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
aria-expanded={menueOffen}
|
||||||
|
aria-controls="hauptmenue"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Link href="/heute" className="flex-1 truncate font-semibold">
|
||||||
|
Betreuungs-Cockpit
|
||||||
|
</Link>
|
||||||
|
{benutzerbereich}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* --- Schublade am Handy ------------------------------------------- */}
|
||||||
|
{menueOffen && (
|
||||||
|
<div className="no-print fixed inset-0 z-50 lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={() => setMenueOffen(false)}
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="hauptmenue"
|
||||||
|
className="absolute inset-y-0 left-0 flex w-80 max-w-[85vw] flex-col bg-card shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center justify-between border-b px-4">
|
||||||
|
<span className="font-semibold">Menü</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMenueOffen(false)}
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border-b p-4">{kopfzeile}</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<Navigation eintraege={navigation} beimKlick={() => setMenueOffen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Inhalt -------------------------------------------------------- */}
|
||||||
|
<main id="inhalt" className="min-w-0 flex-1 focus:outline-none" tabIndex={-1}>
|
||||||
|
<div className={cn('mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8')}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* --- Schnellerfassung --------------------------------------------- */}
|
||||||
|
{schnellerfassung}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fester Knopf unten rechts. Am Handy immer erreichbar, ohne nach oben
|
||||||
|
* scrollen zu muessen.
|
||||||
|
*/
|
||||||
|
export function SchnellerfassungKnopf({ eintraege }: { eintraege: { titel: string; href: string }[] }) {
|
||||||
|
const [offen, setOffen] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="no-print fixed bottom-5 right-5 z-40 flex flex-col items-end gap-2">
|
||||||
|
{offen && (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{eintraege.map((eintrag) => (
|
||||||
|
<Link
|
||||||
|
key={eintrag.href}
|
||||||
|
href={eintrag.href}
|
||||||
|
onClick={() => setOffen(false)}
|
||||||
|
className="rounded-full border bg-card px-4 py-2.5 text-sm font-medium shadow-lg hover:bg-accent"
|
||||||
|
>
|
||||||
|
{eintrag.titel}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="size-14 rounded-full shadow-lg"
|
||||||
|
onClick={() => setOffen((o) => !o)}
|
||||||
|
aria-expanded={offen}
|
||||||
|
aria-label={offen ? 'Schnellerfassung schließen' : 'Schnell erfassen'}
|
||||||
|
>
|
||||||
|
<Plus className={cn('size-6 transition-transform', offen && 'rotate-45')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { initialen } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Thema = 'hell' | 'dunkel' | 'system'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzermenue mit Themenwahl und Abmelden.
|
||||||
|
*
|
||||||
|
* Das Thema wird im localStorage gemerkt und sofort auf <html> gesetzt -
|
||||||
|
* dieselbe Logik wie im Startskript im Layout, damit es beim naechsten
|
||||||
|
* Laden nicht flackert.
|
||||||
|
*/
|
||||||
|
export function BenutzerMenue({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
rolle,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
rolle: string
|
||||||
|
}) {
|
||||||
|
const [thema, setThema] = React.useState<Thema>('system')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const gespeichert = (localStorage.getItem('bc-thema') as Thema | null) ?? 'system'
|
||||||
|
setThema(gespeichert)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function themaSetzen(neu: Thema) {
|
||||||
|
setThema(neu)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('bc-thema', neu)
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt - dann gilt die Wahl nur fuer diese Sitzung. */
|
||||||
|
}
|
||||||
|
const dunkel =
|
||||||
|
neu === 'dunkel' ||
|
||||||
|
(neu === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.classList.toggle('dark', dunkel)
|
||||||
|
document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="w-full justify-start gap-3 px-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="grid size-8 shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{initialen(name)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-left lg:block">
|
||||||
|
<span className="block truncate text-sm font-medium">{name}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{rolle}</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="truncate font-medium">{name}</div>
|
||||||
|
<div className="truncate text-xs font-normal text-muted-foreground">{email}</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/konto">
|
||||||
|
<UserIcon className="size-4" />
|
||||||
|
Mein Konto
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/konto/zwei-faktor">
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
Zweiter Faktor
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/einstellungen">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
Darstellung
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onSelect={() => themaSetzen('hell')}>
|
||||||
|
<Sun className="size-4" />
|
||||||
|
Hell
|
||||||
|
{thema === 'hell' && <span className="ml-auto text-xs">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => themaSetzen('dunkel')}>
|
||||||
|
<Moon className="size-4" />
|
||||||
|
Dunkel
|
||||||
|
{thema === 'dunkel' && <span className="ml-auto text-xs">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => themaSetzen('system')}>
|
||||||
|
<Monitor className="size-4" />
|
||||||
|
Wie das System
|
||||||
|
{thema === 'system' && <span className="ml-auto text-xs">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={() => void signOut({ callbackUrl: '/anmelden' })}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Abmelden
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { UserRound } from 'lucide-react'
|
||||||
|
import { Select } from '@/components/ui/select'
|
||||||
|
import { betreuteWechseln } from '@/server/actions/betreute-wechseln'
|
||||||
|
import { formatDatum } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeige und Wechsel der aktiven betreuten Person.
|
||||||
|
*
|
||||||
|
* Bei nur einer betreuten Person - dem Regelfall - wird kein Auswahlfeld
|
||||||
|
* gezeigt, sondern nur der Name. Ein Auswahlfeld mit genau einem Eintrag
|
||||||
|
* ist eine Bedienhuerde ohne Nutzen.
|
||||||
|
*/
|
||||||
|
export function BetreuteAuswahl({
|
||||||
|
betreute,
|
||||||
|
aktiveId,
|
||||||
|
}: {
|
||||||
|
betreute: { id: string; vorname: string; nachname: string; geburtsdatum: string | null }[]
|
||||||
|
aktiveId: string
|
||||||
|
}) {
|
||||||
|
const [laeuft, starten] = useTransition()
|
||||||
|
const aktive = betreute.find((b) => b.id === aktiveId)
|
||||||
|
|
||||||
|
if (betreute.length <= 1) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<UserRound className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">
|
||||||
|
{aktive ? `${aktive.vorname} ${aktive.nachname}` : 'Keine betreute Person'}
|
||||||
|
</div>
|
||||||
|
{aktive?.geburtsdatum && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
geb. {formatDatum(aktive.geburtsdatum)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="betreute-auswahl" className="text-xs font-medium text-muted-foreground">
|
||||||
|
Betreute Person
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="betreute-auswahl"
|
||||||
|
value={aktiveId}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.currentTarget.value
|
||||||
|
starten(() => void betreuteWechseln(id))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{betreute.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.nachname}, {b.vorname}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Formularfeld mit Beschriftung, Erklaerung und Fehlermeldung.
|
||||||
|
*
|
||||||
|
* Der Erklaerungstext ist kein Beiwerk: Diese Anwendung benutzt an vielen
|
||||||
|
* Stellen Begriffe aus dem Betreuungs- und Sozialrecht. Wer sie nicht
|
||||||
|
* kennt, soll sie hier erklaert bekommen, statt nachschlagen zu muessen.
|
||||||
|
*
|
||||||
|
* Beschriftung, Erklaerung und Fehler sind ueber aria-describedby mit dem
|
||||||
|
* Eingabefeld verbunden - Screenreader lesen sie mit vor.
|
||||||
|
*/
|
||||||
|
export function Feld({
|
||||||
|
id,
|
||||||
|
beschriftung,
|
||||||
|
erklaerung,
|
||||||
|
fehler,
|
||||||
|
pflicht,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
beschriftung: string
|
||||||
|
erklaerung?: React.ReactNode
|
||||||
|
fehler?: string | string[] | null
|
||||||
|
pflicht?: boolean
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const fehlertext = Array.isArray(fehler) ? fehler[0] : fehler
|
||||||
|
const erklaerungId = erklaerung ? `${id}-erklaerung` : undefined
|
||||||
|
const fehlerId = fehlertext ? `${id}-fehler` : undefined
|
||||||
|
const beschriftungVon = [erklaerungId, fehlerId].filter(Boolean).join(' ') || undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-1.5', className)}>
|
||||||
|
<Label htmlFor={id}>
|
||||||
|
{beschriftung}
|
||||||
|
{pflicht && (
|
||||||
|
<span className="ml-1 text-destructive" aria-label="Pflichtfeld">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{React.isValidElement(children)
|
||||||
|
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
|
||||||
|
id,
|
||||||
|
'aria-describedby': beschriftungVon,
|
||||||
|
'aria-invalid': fehlertext ? true : undefined,
|
||||||
|
'aria-required': pflicht || undefined,
|
||||||
|
})
|
||||||
|
: children}
|
||||||
|
|
||||||
|
{erklaerung && (
|
||||||
|
<p id={erklaerungId} className="text-sm text-muted-foreground">
|
||||||
|
{erklaerung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fehlertext && (
|
||||||
|
<p id={fehlerId} className="flex items-start gap-1.5 text-sm font-medium text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||||
|
{fehlertext}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppiert zusammengehoerige Felder mit einer Ueberschrift. */
|
||||||
|
export function Feldgruppe({
|
||||||
|
titel,
|
||||||
|
beschreibung,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
beschreibung?: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className={cn('space-y-4', className)}>
|
||||||
|
<legend className="sr-only">{titel}</legend>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">{titel}</h2>
|
||||||
|
{beschreibung && <p className="mt-0.5 text-sm text-muted-foreground">{beschreibung}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">{children}</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
CalendarClock,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
|
HeartPulse,
|
||||||
|
Home,
|
||||||
|
Landmark,
|
||||||
|
ScrollText,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { Modul } from '@prisma/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauptnavigation.
|
||||||
|
*
|
||||||
|
* Reihenfolge nach Haeufigkeit im Alltag, nicht nach fachlicher Systematik:
|
||||||
|
* Heute steht oben, weil ich morgens dort anfange. Berichte stehen unten,
|
||||||
|
* weil ich sie zweimal im Jahr brauche.
|
||||||
|
*/
|
||||||
|
export type NavEintrag = {
|
||||||
|
titel: string
|
||||||
|
href: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
modul: Modul
|
||||||
|
/** Zaehler, der als kleine Blase erscheint (z.B. offene Fristen). */
|
||||||
|
zaehler?: number
|
||||||
|
/** Faerbt die Blase rot statt neutral. */
|
||||||
|
dringend?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_STRUKTUR: Omit<NavEintrag, 'zaehler' | 'dringend'>[] = [
|
||||||
|
{ titel: 'Heute', href: '/heute', icon: Home, modul: 'STAMMDATEN' },
|
||||||
|
{ titel: 'Fristen', href: '/fristen', icon: CalendarClock, modul: 'FRISTEN' },
|
||||||
|
{ titel: 'Vorgänge', href: '/vorgaenge', icon: FolderKanban, modul: 'VORGAENGE' },
|
||||||
|
{ titel: 'Dokumente', href: '/dokumente', icon: FileText, modul: 'DOKUMENTE' },
|
||||||
|
{ titel: 'Vermögen & Kasse', href: '/finanzen', icon: Banknote, modul: 'FINANZEN' },
|
||||||
|
{ titel: 'Sozialhilfe', href: '/sozialhilfe', icon: Landmark, modul: 'SOZIALHILFE' },
|
||||||
|
{ titel: 'Immobilie', href: '/immobilie', icon: Building2, modul: 'IMMOBILIE' },
|
||||||
|
{ titel: 'Verträge', href: '/vertraege', icon: Shield, modul: 'VERTRAEGE' },
|
||||||
|
{ titel: 'Heim & Pflege', href: '/pflege', icon: HeartPulse, modul: 'GESUNDHEIT' },
|
||||||
|
{ titel: 'Kontakte', href: '/kontakte', icon: Users, modul: 'KONTAKTE' },
|
||||||
|
{ titel: 'Berichte', href: '/berichte', icon: ScrollText, modul: 'BERICHTE' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Navigation({
|
||||||
|
eintraege,
|
||||||
|
beimKlick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
eintraege: NavEintrag[]
|
||||||
|
beimKlick?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const pfad = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Hauptnavigation" className={cn('space-y-0.5', className)}>
|
||||||
|
{eintraege.map((eintrag) => {
|
||||||
|
// "/fristen/neu" soll "Fristen" ebenfalls hervorheben.
|
||||||
|
const aktiv = pfad === eintrag.href || pfad.startsWith(`${eintrag.href}/`)
|
||||||
|
const Icon = eintrag.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={eintrag.href}
|
||||||
|
href={eintrag.href}
|
||||||
|
onClick={beimKlick}
|
||||||
|
aria-current={aktiv ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
aktiv
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-foreground/80 hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{eintrag.titel}</span>
|
||||||
|
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'min-w-6 rounded-full px-1.5 py-0.5 text-center text-xs font-semibold tabular-nums',
|
||||||
|
eintrag.dringend
|
||||||
|
? 'bg-ampel-rot text-white'
|
||||||
|
: aktiv
|
||||||
|
? 'bg-primary-foreground/20 text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{eintrag.zaehler > 99 ? '99+' : eintrag.zaehler}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Scale } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der stehende Hinweis, dass diese Anwendung keine Rechtsberatung ist.
|
||||||
|
*
|
||||||
|
* Er steht an JEDER Stelle, an der ein Paragraph genannt wird. Das ist
|
||||||
|
* keine Formalie: Die App rechnet Fristen aus und schlaegt Einordnungen
|
||||||
|
* vor, aber sie kennt den Einzelfall nicht. Wer sich darauf verlaesst,
|
||||||
|
* statt beim Rechtspfleger nachzufragen, kann Geld und Anspruch verlieren.
|
||||||
|
*/
|
||||||
|
export function Rechtshinweis({
|
||||||
|
rechtsgrundlage,
|
||||||
|
className,
|
||||||
|
kurz = false,
|
||||||
|
}: {
|
||||||
|
rechtsgrundlage?: string
|
||||||
|
className?: string
|
||||||
|
kurz?: boolean
|
||||||
|
}) {
|
||||||
|
if (kurz) {
|
||||||
|
return (
|
||||||
|
<p className={cn('text-xs text-muted-foreground', className)}>
|
||||||
|
{rechtsgrundlage && <span className="font-medium">{rechtsgrundlage} · </span>}
|
||||||
|
Keine Rechtsberatung. Im Zweifel bei Gericht, Bezirk oder Anwalt nachfragen.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2.5 rounded-md border border-border bg-muted/50 p-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{rechtsgrundlage && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">{rechtsgrundlage}</span>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
Dieser Hinweis dient der eigenen Orientierung und ersetzt{' '}
|
||||||
|
<strong className="font-medium">keine Rechtsberatung</strong>. Er ersetzt insbesondere nicht
|
||||||
|
die Rückfrage beim Betreuungsgericht, beim Sozialhilfeträger oder bei einem Anwalt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ChevronRight, Inbox } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopfzeile einer Seite: Ueberschrift, Erklaerung, Aktionen.
|
||||||
|
*
|
||||||
|
* Die Erklaerung beantwortet moeglichst die Frage "was muss ich hier als
|
||||||
|
* Naechstes tun?" - nicht "was steht hier".
|
||||||
|
*/
|
||||||
|
export function SeitenKopf({
|
||||||
|
titel,
|
||||||
|
beschreibung,
|
||||||
|
aktionen,
|
||||||
|
pfad,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
beschreibung?: React.ReactNode
|
||||||
|
aktionen?: React.ReactNode
|
||||||
|
pfad?: { titel: string; href?: string }[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{pfad && pfad.length > 0 && (
|
||||||
|
<nav aria-label="Pfad">
|
||||||
|
<ol className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
{pfad.map((eintrag, i) => (
|
||||||
|
<li key={`${eintrag.titel}-${i}`} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <ChevronRight className="size-3.5" aria-hidden="true" />}
|
||||||
|
{eintrag.href ? (
|
||||||
|
<Link href={eintrag.href} className="hover:text-foreground hover:underline">
|
||||||
|
{eintrag.titel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span aria-current="page">{eintrag.titel}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{titel}</h1>
|
||||||
|
{beschreibung && (
|
||||||
|
<div className="max-w-2xl text-muted-foreground">{beschreibung}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{aktionen && <div className="flex shrink-0 flex-wrap gap-2">{aktionen}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leerer Zustand. Zeigt nie nur "keine Daten", sondern immer den naechsten
|
||||||
|
* moeglichen Schritt - eine leere Liste soll nicht ratlos machen.
|
||||||
|
*/
|
||||||
|
export function Leerzustand({
|
||||||
|
titel,
|
||||||
|
beschreibung,
|
||||||
|
aktion,
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
beschreibung?: React.ReactNode
|
||||||
|
aktion?: React.ReactNode
|
||||||
|
icon?: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-14 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mb-3 size-8 text-muted-foreground" aria-hidden={true} />
|
||||||
|
<h3 className="text-base font-semibold">{titel}</h3>
|
||||||
|
{beschreibung && (
|
||||||
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">{beschreibung}</p>
|
||||||
|
)}
|
||||||
|
{aktion && <div className="mt-5">{aktion}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const alertVarianten = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 ' +
|
||||||
|
'[&>svg]:size-5 [&>svg~*]:pl-8',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
hinweis: 'border-primary/30 bg-primary/5 text-foreground [&>svg]:text-primary',
|
||||||
|
warnung:
|
||||||
|
'border-ampel-orange/40 bg-ampel-orange/10 text-foreground [&>svg]:text-ampel-orange',
|
||||||
|
fehler: 'border-destructive/50 bg-destructive/10 text-foreground [&>svg]:text-destructive',
|
||||||
|
erfolg: 'border-ampel-gruen/40 bg-ampel-gruen/10 text-foreground [&>svg]:text-ampel-gruen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVarianten>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVarianten({ variant }), className)} {...props} />
|
||||||
|
))
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.ComponentProps<'h5'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn('mb-1 font-semibold leading-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
AlertTitle.displayName = 'AlertTitle'
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
AlertDescription.displayName = 'AlertDescription'
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuskennzeichen. Die Ampelvarianten benutzen Farbe UND Text - Farbe
|
||||||
|
* allein waere fuer farbfehlsichtige Nutzer keine Information.
|
||||||
|
*/
|
||||||
|
const badgeVarianten = cva(
|
||||||
|
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium ' +
|
||||||
|
'transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
rot: 'border-ampel-rot/30 bg-ampel-rot/10 text-ampel-rot',
|
||||||
|
orange: 'border-ampel-orange/30 bg-ampel-orange/10 text-ampel-orange',
|
||||||
|
gelb: 'border-ampel-gelb/30 bg-ampel-gelb/10 text-ampel-gelb',
|
||||||
|
gruen: 'border-ampel-gruen/30 bg-ampel-gruen/10 text-ampel-gruen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||||
|
VariantProps<typeof badgeVarianten> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVarianten({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVarianten }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltflaeche.
|
||||||
|
*
|
||||||
|
* Die Groesse "default" ist bewusst hoeher als bei shadcn/ui im Original:
|
||||||
|
* 2.75rem entspricht 44px und damit der Mindestgroesse fuer Touchziele.
|
||||||
|
* Ich bediene die App oft am Handy im Heim oder auf dem Amt.
|
||||||
|
*/
|
||||||
|
const buttonVarianten = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ' +
|
||||||
|
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
|
||||||
|
'focus-visible:ring-offset-2 focus-visible:ring-offset-background ' +
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-11 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3 text-sm',
|
||||||
|
lg: 'h-12 rounded-md px-8 text-base',
|
||||||
|
icon: 'h-11 w-11',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVarianten> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Komponente = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Komponente
|
||||||
|
className={cn(buttonVarianten({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVarianten }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLHeadingElement, React.ComponentProps<'h3'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-tight tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.ComponentProps<'p'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center gap-2 p-5 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natives Kontrollkaestchen. Groesser als der Browserstandard, damit es
|
||||||
|
* am Handy zuverlaessig getroffen wird.
|
||||||
|
*/
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'size-5 shrink-0 cursor-pointer rounded border-input accent-primary',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Checkbox.displayName = 'Checkbox'
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none',
|
||||||
|
'transition-colors focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texteingabe. Schriftgroesse mindestens 16px (text-base), sonst zoomt
|
||||||
|
* iOS-Safari beim Hineintippen das ganze Formular heran.
|
||||||
|
*/
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-base',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst das native <select> statt einer nachgebauten Liste.
|
||||||
|
*
|
||||||
|
* Am Handy oeffnet iOS und Android dafuer das eingebaute Rad bzw. die
|
||||||
|
* Systemliste - schneller zu bedienen als jede nachgebaute Variante, und
|
||||||
|
* mit Screenreadern und Tastatur funktioniert es ohne Zutun.
|
||||||
|
*/
|
||||||
|
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full appearance-none rounded-md border border-input bg-background',
|
||||||
|
'px-3 py-2 pr-9 text-base',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'aria-[invalid=true]:border-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Select.displayName = 'Select'
|
||||||
|
|
||||||
|
export { Select }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabelle. Der Rahmen scrollt waagerecht - am Handy darf niemals die ganze
|
||||||
|
* Seite seitlich wandern, nur die Tabelle selbst.
|
||||||
|
*/
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.ComponentProps<'table'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-x-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.ComponentProps<'thead'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<HTMLTableSectionElement, React.ComponentProps<'tbody'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.ComponentProps<'tr'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b transition-colors hover:bg-muted/50', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<HTMLTableCellElement, React.ComponentProps<'th'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-11 px-3 text-left align-middle text-xs font-semibold uppercase tracking-wide',
|
||||||
|
'text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.ComponentProps<'td'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('px-3 py-3 align-middle', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-base',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'aria-[invalid=true]:border-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { AuthError } from 'next-auth'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { signIn } from '@/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Action fuer das Anmeldeformular.
|
||||||
|
*
|
||||||
|
* Die Fehlermeldung ist absichtlich unspezifisch: Ob die E-Mail unbekannt
|
||||||
|
* ist oder das Passwort falsch, geht niemanden etwas an, der davorsitzt.
|
||||||
|
* Nur die Sperre wird konkret benannt - sonst probiert man ratlos weiter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AnmeldeSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse eingeben.'),
|
||||||
|
passwort: z.string().min(1, 'Bitte das Passwort eingeben.'),
|
||||||
|
code: z.string().trim().default(''),
|
||||||
|
weiter: z.string().default('/heute'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AnmeldeZustand = {
|
||||||
|
fehler?: string
|
||||||
|
felder?: { email?: string; passwort?: string }
|
||||||
|
/** Vorbelegung, damit die E-Mail nach einem Fehlversuch stehen bleibt. */
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
|
||||||
|
* Bewusst ueber das digest-Feld statt ueber einen Import aus next/dist -
|
||||||
|
* interne Pfade aendern sich zwischen Nebenversionen.
|
||||||
|
*/
|
||||||
|
function istWeiterleitung(fehler: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof fehler === 'object' &&
|
||||||
|
fehler !== null &&
|
||||||
|
'digest' in fehler &&
|
||||||
|
typeof (fehler as { digest?: unknown }).digest === 'string' &&
|
||||||
|
(fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
|
||||||
|
function sicheresZiel(ziel: string): string {
|
||||||
|
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehlertext(code: string): string {
|
||||||
|
const [grund, wert] = code.split(':')
|
||||||
|
|
||||||
|
switch (grund) {
|
||||||
|
case 'gesperrt': {
|
||||||
|
const sekunden = Number.parseInt(wert ?? '0', 10)
|
||||||
|
const minuten = Math.ceil(sekunden / 60)
|
||||||
|
return (
|
||||||
|
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
|
||||||
|
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'konto-inaktiv':
|
||||||
|
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
|
||||||
|
case 'code-falsch':
|
||||||
|
return (
|
||||||
|
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts stimmt — ' +
|
||||||
|
'TOTP-Codes hängen an der Uhr. Alternativ kannst du einen Wiederherstellungscode eingeben.'
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return 'E-Mail-Adresse, Passwort oder Code stimmen nicht.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anmelden(
|
||||||
|
_vorher: AnmeldeZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AnmeldeZustand> {
|
||||||
|
const gelesen = AnmeldeSchema.safeParse({
|
||||||
|
email: formular.get('email'),
|
||||||
|
passwort: formular.get('passwort'),
|
||||||
|
code: formular.get('code'),
|
||||||
|
weiter: formular.get('weiter') ?? '/heute',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = gelesen.error.flatten().fieldErrors
|
||||||
|
return {
|
||||||
|
fehler: 'Bitte prüfe deine Eingaben.',
|
||||||
|
felder: { email: felder.email?.[0], passwort: felder.passwort?.[0] },
|
||||||
|
email: String(formular.get('email') ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, passwort, code, weiter } = gelesen.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn('zugangsdaten', {
|
||||||
|
email,
|
||||||
|
passwort,
|
||||||
|
code,
|
||||||
|
redirectTo: sicheresZiel(weiter),
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
// Bei Erfolg wirft signIn eine Weiterleitung - die muss durch.
|
||||||
|
if (istWeiterleitung(fehler)) throw fehler
|
||||||
|
|
||||||
|
if (fehler instanceof AuthError) {
|
||||||
|
const code = 'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'daten-falsch'
|
||||||
|
return { fehler: fehlertext(code), email }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird nicht erreicht - signIn leitet weiter.
|
||||||
|
return {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { erfordereBenutzer, meineBetreuten, COOKIE_AKTIVE_BETREUTE } from '@/lib/auth/sitzung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt die aktive betreute Person.
|
||||||
|
*
|
||||||
|
* Die Auswahl wird nur gesetzt, wenn der Benutzer auf diese Person auch
|
||||||
|
* Zugriff hat - sonst liesse sich ueber ein manipuliertes Cookie fremde
|
||||||
|
* Daten anzeigen.
|
||||||
|
*/
|
||||||
|
export async function betreuteWechseln(betreuteId: string): Promise<void> {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
const erlaubt = await meineBetreuten(benutzer)
|
||||||
|
|
||||||
|
if (!erlaubt.some((b) => b.id === betreuteId)) {
|
||||||
|
throw new Error('Kein Zugriff auf diese betreute Person.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keks = await cookies()
|
||||||
|
keks.set(COOKIE_AKTIVE_BETREUTE, betreuteId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { passwortHashen } from '@/lib/crypto/hash'
|
||||||
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anlegen des allerersten Zugangs.
|
||||||
|
*
|
||||||
|
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer
|
||||||
|
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von
|
||||||
|
* aussen einen Administratorzugang anlegen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PASSWORT_MINDESTLAENGE = 12
|
||||||
|
|
||||||
|
const ZugangSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
|
||||||
|
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'),
|
||||||
|
passwort: z
|
||||||
|
.string()
|
||||||
|
.min(
|
||||||
|
PASSWORT_MINDESTLAENGE,
|
||||||
|
`Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Eine Folge aus vier zufälligen ` +
|
||||||
|
`Wörtern ist leichter zu merken und sicherer als ein kurzes Kunstwort.`,
|
||||||
|
)
|
||||||
|
.max(200, 'Höchstens 200 Zeichen.'),
|
||||||
|
passwortWiederholung: z.string(),
|
||||||
|
})
|
||||||
|
.refine((daten) => daten.passwort === daten.passwortWiederholung, {
|
||||||
|
message: 'Die beiden Passwörter stimmen nicht überein.',
|
||||||
|
path: ['passwortWiederholung'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ZugangZustand = {
|
||||||
|
fehler?: string
|
||||||
|
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>>
|
||||||
|
werte?: { name?: string; email?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function erstenZugangAnlegen(
|
||||||
|
_vorher: ZugangZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<ZugangZustand> {
|
||||||
|
const vorhandene = await db.user.count()
|
||||||
|
if (vorhandene > 0) {
|
||||||
|
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = ZugangSchema.safeParse({
|
||||||
|
name: formular.get('name'),
|
||||||
|
email: formular.get('email'),
|
||||||
|
passwort: formular.get('passwort'),
|
||||||
|
passwortWiederholung: formular.get('passwortWiederholung'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = gelesen.error.flatten().fieldErrors
|
||||||
|
return {
|
||||||
|
fehler: 'Bitte prüfe deine Eingaben.',
|
||||||
|
felder: {
|
||||||
|
name: felder.name?.[0],
|
||||||
|
email: felder.email?.[0],
|
||||||
|
passwort: felder.passwort?.[0],
|
||||||
|
passwortWiederholung: felder.passwortWiederholung?.[0],
|
||||||
|
},
|
||||||
|
werte: {
|
||||||
|
name: String(formular.get('name') ?? ''),
|
||||||
|
email: String(formular.get('email') ?? ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, passwort } = gelesen.data
|
||||||
|
|
||||||
|
const benutzer = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
passwortHash: await passwortHashen(passwort),
|
||||||
|
// Der erste Zugang verwaltet auch die Anwendung.
|
||||||
|
rolle: 'ADMIN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'User',
|
||||||
|
entitaetId: benutzer.id,
|
||||||
|
bezeichnung: `${name} (erster Zugang)`,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: email,
|
||||||
|
userName: name,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Weiter zur Anmeldung: der zweite Faktor wird direkt danach eingerichtet.
|
||||||
|
redirect('/anmelden?neu=1')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user