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,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 type { Rolle } from '@prisma/client'
|
||||
import { db } from '@/lib/db'
|
||||
@@ -25,18 +25,23 @@ import { protokollieren } from '@/lib/audit'
|
||||
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
|
||||
*/
|
||||
|
||||
export class AnmeldeFehler extends Error {
|
||||
constructor(
|
||||
public readonly grund:
|
||||
| 'daten-falsch'
|
||||
| 'gesperrt'
|
||||
| 'konto-inaktiv'
|
||||
| 'zweiter-faktor-fehlt'
|
||||
| 'code-falsch',
|
||||
public readonly nochSekunden?: number,
|
||||
) {
|
||||
export type AnmeldeGrund =
|
||||
| 'daten-falsch'
|
||||
| 'gesperrt'
|
||||
| 'konto-inaktiv'
|
||||
| 'code-falsch'
|
||||
|
||||
/**
|
||||
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
|
||||
* 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)
|
||||
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