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,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 }
|
||||
Reference in New Issue
Block a user