feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte

Die Schnellerfassung braucht ein Feld und einen Knopf; wer beim Erfassen
erst ein Formular ausfüllen muss, erfasst nichts. Der Fokus bleibt danach
im Feld, damit mehrere Sachen hintereinander hineingehen.

Die Filter der Aufgabenliste stehen in der Adresse, nicht im Zustand der
Komponente — so lässt sich eine gefilterte Liste als Lesezeichen ablegen,
und der Zurück-Knopf tut, was er soll.

Die Startseite zeigt jetzt, was überfällig, heute oder morgen fällig ist,
statt nur die Bereiche aufzuzählen.

Für die Auswahlfelder bewusst das native <select>: es bedient sich auf
dem Handy mit dem Systemdialog und ist ohne Zusatzcode tastaturbedienbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 16:17:44 +02:00
co-authored by Claude Opus 5
parent b3ca1c9ce7
commit a4a889fcb8
19 changed files with 1859 additions and 43 deletions
@@ -0,0 +1,233 @@
'use client'
import { useTransition, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AUFWAND_MAX_MINUTEN,
PRIORITAET_NAME,
STATUS_NAME,
TYP_NAME,
moeglicheUebergaenge,
} from '@/lib/aufgabe'
import type { AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
import { aufgabeAendern, aufgabeAnlegen } from '@/server/aktionen/aufgaben'
export type BereichWahl = { id: string; name: string }
export type ProjektWahl = { id: string; name: string }
type Eigenschaften = {
/** Fehlt sie, wird eine neue Aufgabe angelegt. */
aufgabe?: AufgabeAnzeige
bereiche: BereichWahl[]
projekte: ProjektWahl[]
/** Vorbelegung beim Anlegen. */
vorgabe?: { bereichId?: string; projektId?: string; typ?: string }
onFertig?: () => void
}
const TYPEN = ['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN'] as const
export function AufgabeFormular({
aufgabe,
bereiche,
projekte,
vorgabe,
onFertig,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
// Aus einem abgeschlossenen Zustand geht es nur zurück nach OFFEN. Die
// Auswahl zeigt deshalb nur, was von hier aus wirklich möglich ist.
const statusAuswahl = aufgabe
? [aufgabe.status, ...moeglicheUebergaenge(aufgabe.status)]
: (['OFFEN', 'GEPLANT', 'WARTET'] as const)
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault()
const formular = ereignis.currentTarget
const daten = new FormData(formular)
starte(async () => {
const antwort = aufgabe
? await aufgabeAendern(aufgabe.id, daten)
: await aufgabeAnlegen(daten)
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
if (!aufgabe) formular.reset()
router.refresh()
onFertig?.()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
const kennung = aufgabe?.id ?? 'neu'
return (
<form onSubmit={absenden} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`titel-${kennung}`}>Titel</Label>
<Input
id={`titel-${kennung}`}
name="titel"
defaultValue={aufgabe?.titel ?? ''}
required
maxLength={200}
aria-invalid={Boolean(felder.titel)}
/>
{felder.titel && <p className="text-gefahr text-xs">{felder.titel}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`notiz-${kennung}`}>Notiz</Label>
<Textarea
id={`notiz-${kennung}`}
name="notiz"
defaultValue={aufgabe?.notiz ?? ''}
maxLength={5000}
placeholder="Was dazugehört, aber nicht in den Titel passt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`typ-${kennung}`}>Art</Label>
<Select
id={`typ-${kennung}`}
name="typ"
defaultValue={aufgabe?.typ ?? vorgabe?.typ ?? 'AUFGABE'}
>
{TYPEN.map((typ) => (
<option key={typ} value={typ}>
{TYP_NAME[typ]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`status-${kennung}`}>Status</Label>
<Select
id={`status-${kennung}`}
name="status"
defaultValue={aufgabe?.status ?? 'OFFEN'}
aria-invalid={Boolean(felder.status)}
>
{[...new Set(statusAuswahl)].map((status) => (
<option key={status} value={status}>
{STATUS_NAME[status]}
</option>
))}
</Select>
{felder.status && <p className="text-gefahr text-xs">{felder.status}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`prioritaet-${kennung}`}>Priorität</Label>
<Select
id={`prioritaet-${kennung}`}
name="prioritaet"
defaultValue={String(aufgabe?.prioritaet ?? 3)}
>
{[1, 2, 3, 4, 5].map((stufe) => (
<option key={stufe} value={stufe}>
{stufe} – {PRIORITAET_NAME[stufe]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`aufwand-${kennung}`}>Aufwand in Minuten</Label>
<Input
id={`aufwand-${kennung}`}
name="aufwandMinuten"
type="number"
min={1}
max={AUFWAND_MAX_MINUTEN}
step={15}
defaultValue={aufgabe?.aufwandMinuten ?? ''}
placeholder="noch nicht geschätzt"
aria-invalid={Boolean(felder.aufwandMinuten)}
/>
{felder.aufwandMinuten && (
<p className="text-gefahr text-xs">{felder.aufwandMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`faellig-${kennung}`}>Fällig am</Label>
<Input
id={`faellig-${kennung}`}
name="faelligAm"
type="date"
defaultValue={aufgabe?.faelligWert ?? ''}
aria-invalid={Boolean(felder.faelligAm)}
/>
{felder.faelligAm && <p className="text-gefahr text-xs">{felder.faelligAm}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`bereich-${kennung}`}>Bereich</Label>
<Select
id={`bereich-${kennung}`}
name="bereichId"
defaultValue={aufgabe?.bereich?.id ?? vorgabe?.bereichId ?? ''}
aria-invalid={Boolean(felder.bereichId)}
>
<option value="">Inbox – noch nicht einsortiert</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
{felder.bereichId && <p className="text-gefahr text-xs">{felder.bereichId}</p>}
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={`projekt-${kennung}`}>Projekt</Label>
<Select
id={`projekt-${kennung}`}
name="projektId"
defaultValue={aufgabe?.projekt?.id ?? vorgabe?.projektId ?? ''}
aria-invalid={Boolean(felder.projektId)}
>
<option value="">Kein Projekt</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</Select>
{felder.projektId && <p className="text-gefahr text-xs">{felder.projektId}</p>}
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
{aufgabe ? 'Speichern' : 'Aufgabe anlegen'}
</Button>
{onFertig && (
<Button type="button" art="leise" onClick={onFertig} disabled={laeuft}>
Abbrechen
</Button>
)}
</div>
</form>
)
}
@@ -0,0 +1,73 @@
'use client'
import { useState } from 'react'
import { AufgabeFormular } from '@/components/aufgaben/aufgabe-formular'
import { AufgabenZeile } from '@/components/aufgaben/aufgaben-zeile'
import { Card, CardInhalt } from '@/components/ui/card'
import type { AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
type Eigenschaften = {
aufgaben: AufgabeAnzeige[]
bereiche: { id: string; name: string; farbe: string }[]
projekte: { id: string; name: string }[]
/** Text, wenn nichts da ist. */
leerText: string
mitEinsortieren?: boolean
mitUebernehmen?: boolean
/** Bearbeiten direkt in der Liste erlauben. */
mitBearbeiten?: boolean
}
export function AufgabenListe({
aufgaben,
bereiche,
projekte,
leerText,
mitEinsortieren = false,
mitUebernehmen = false,
mitBearbeiten = true,
}: Eigenschaften) {
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
if (aufgaben.length === 0) {
return (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
{leerText}
</CardInhalt>
</Card>
)
}
return (
<Card>
<CardInhalt className="py-0">
<ul>
{aufgaben.map((aufgabe) =>
bearbeitet === aufgabe.id ? (
<li key={aufgabe.id} className="border-rand border-b py-4 last:border-b-0">
<AufgabeFormular
aufgabe={aufgabe}
bereiche={bereiche}
projekte={projekte}
onFertig={() => setBearbeitet(null)}
/>
</li>
) : (
<AufgabenZeile
key={aufgabe.id}
aufgabe={aufgabe}
bereiche={bereiche}
mitEinsortieren={mitEinsortieren}
mitUebernehmen={mitUebernehmen}
{...(mitBearbeiten
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
: {})}
/>
),
)}
</ul>
</CardInhalt>
</Card>
)
}
+263
View File
@@ -0,0 +1,263 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { Select } from '@/components/ui/select'
import {
PRIORITAET_NAME,
QUELLE_NAME,
STATUS_NAME,
TYP_NAME,
istAbgeschlossen,
} from '@/lib/aufgabe'
import { faelligkeitFarbe, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
import { alsDauer } from '@/lib/zeit'
import { cn } from '@/lib/utils'
import {
aufgabeLoeschen,
einsortieren,
ideeUebernehmen,
statusSetzen,
type Rueckmeldung,
} from '@/server/aktionen/aufgaben'
export type BereichWahl = { id: string; name: string; farbe: string }
type Eigenschaften = {
aufgabe: AufgabeAnzeige
/** Bereiche zur Auswahl; nur nötig, wenn einsortiert werden soll. */
bereiche?: BereichWahl[]
/** Zeigt das Auswahlfeld zum Einsortieren (Inbox). */
mitEinsortieren?: boolean
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
mitUebernehmen?: boolean
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
}
export function AufgabenZeile({
aufgabe,
bereiche = [],
mitEinsortieren = false,
mitUebernehmen = false,
onBearbeiten,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false)
const abgeschlossen = istAbgeschlossen(aufgabe.status)
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function statusWechseln(neu: string) {
const daten = new FormData()
daten.set('id', aufgabe.id)
daten.set('status', neu)
mitRueckmeldung(() => statusSetzen(daten))
}
function inBereich(bereichId: string) {
if (!bereichId) return
const daten = new FormData()
daten.set('id', aufgabe.id)
daten.set('bereichId', bereichId)
mitRueckmeldung(() => einsortieren(daten))
}
function loeschen() {
if (!loeschenBestaetigt) {
setLoeschenBestaetigt(true)
return
}
const daten = new FormData()
daten.set('id', aufgabe.id)
mitRueckmeldung(() => aufgabeLoeschen(daten))
}
function uebernehmen() {
const daten = new FormData()
daten.set('id', aufgabe.id)
mitRueckmeldung(() => ideeUebernehmen(daten))
}
return (
<li
className={cn(
'border-rand flex flex-col gap-2 border-b py-3 last:border-b-0',
abgeschlossen && 'opacity-60',
)}
>
<div className="flex items-start gap-3">
<Button
art="leise"
groesse="quadrat"
className="mt-0.5 shrink-0"
title={abgeschlossen ? 'Wieder öffnen' : 'Als erledigt abhaken'}
disabled={laeuft}
onClick={() => statusWechseln(abgeschlossen ? 'OFFEN' : 'ERLEDIGT')}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : abgeschlossen ? (
<RotateCcw aria-hidden />
) : (
<Check aria-hidden />
)}
<span className="sr-only">
{abgeschlossen ? 'Wieder öffnen' : 'Als erledigt abhaken'}
</span>
</Button>
<div className="min-w-0 flex-1">
<p className={cn('text-sm font-medium', abgeschlossen && 'line-through')}>
{aufgabe.titel}
</p>
{aufgabe.notiz && (
<p className="text-gedaempft-vordergrund mt-0.5 line-clamp-2 text-xs">
{aufgabe.notiz}
</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{aufgabe.bereich ? (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereich.farbe }}
/>
{aufgabe.bereich.name}
</Kennzeichen>
) : (
<Kennzeichen art="betont">Inbox</Kennzeichen>
)}
{aufgabe.typ !== 'AUFGABE' && (
<Kennzeichen>{TYP_NAME[aufgabe.typ]}</Kennzeichen>
)}
{aufgabe.faelligDatum && (
<Kennzeichen art={faelligkeitFarbe(aufgabe)} title={aufgabe.faelligDatum}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.aufwandMinuten !== null && (
<Kennzeichen>{alsDauer(aufgabe.aufwandMinuten)}</Kennzeichen>
)}
{aufgabe.prioritaet <= 2 && (
<Kennzeichen art="warnung">
{PRIORITAET_NAME[aufgabe.prioritaet]}
</Kennzeichen>
)}
{aufgabe.status !== 'OFFEN' && (
<Kennzeichen>{STATUS_NAME[aufgabe.status]}</Kennzeichen>
)}
{aufgabe.projekt && (
<Link
href={`/projekte/${aufgabe.projekt.id}`}
className="text-betont text-xs underline underline-offset-4"
>
{aufgabe.projekt.name}
</Link>
)}
{aufgabe.quelle !== 'MANUELL' &&
(aufgabe.quelleUrl ? (
<a
href={aufgabe.quelleUrl}
target="_blank"
rel="noreferrer noopener"
className="text-gedaempft-vordergrund inline-flex items-center gap-1 text-xs underline underline-offset-4"
>
{QUELLE_NAME[aufgabe.quelle]}
<ExternalLink className="size-3" aria-hidden />
</a>
) : (
<Kennzeichen>{QUELLE_NAME[aufgabe.quelle]}</Kennzeichen>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{onBearbeiten && (
<Button
art="leise"
groesse="quadrat"
title="Bearbeiten"
disabled={laeuft}
onClick={() => onBearbeiten(aufgabe)}
>
<Pencil aria-hidden />
<span className="sr-only">Bearbeiten</span>
</Button>
)}
<Button
art={loeschenBestaetigt ? 'gefahr' : 'leise'}
groesse={loeschenBestaetigt ? 'klein' : 'quadrat'}
title={loeschenBestaetigt ? 'Wirklich löschen' : 'Löschen'}
disabled={laeuft}
onClick={loeschen}
onBlur={() => setLoeschenBestaetigt(false)}
>
<Trash2 aria-hidden />
{loeschenBestaetigt ? 'Wirklich?' : <span className="sr-only">Löschen</span>}
</Button>
</div>
</div>
{(mitEinsortieren || mitUebernehmen) && (
<div className="flex flex-wrap items-center gap-2 pl-12">
{mitEinsortieren && bereiche.length > 0 && (
<>
<label
className="text-gedaempft-vordergrund text-xs"
htmlFor={`bereich-${aufgabe.id}`}
>
Einsortieren nach
</label>
<Select
id={`bereich-${aufgabe.id}`}
className="h-8 w-44"
defaultValue=""
disabled={laeuft}
onChange={(e) => inBereich(e.target.value)}
>
<option value="">Bereich wählen …</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</>
)}
{mitUebernehmen && (
<Button art="umriss" groesse="klein" disabled={laeuft} onClick={uebernehmen}>
Zur Aufgabe machen
</Button>
)}
</div>
)}
</li>
)
}
@@ -0,0 +1,72 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Lightbulb, Loader2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { schnellErfassen } from '@/server/aktionen/aufgaben'
/**
* Ein Feld, ein Knopf, fertig.
*
* Alles Weitere — Bereich, Termin, Aufwand — kommt später beim Einsortieren.
* Wer beim Erfassen erst ein Formular ausfüllen muss, erfasst nichts.
*/
export function Schnellerfassung({ typ = 'AUFGABE' }: { typ?: 'AUFGABE' | 'IDEE' }) {
const router = useRouter()
const [titel, setTitel] = useState('')
const [laeuft, starte] = useTransition()
const feld = useRef<HTMLInputElement>(null)
function absenden(ereignis: React.FormEvent) {
ereignis.preventDefault()
const wert = titel.trim()
if (wert.length < 2) {
toast.error('Bitte mindestens zwei Zeichen eingeben.')
return
}
const daten = new FormData()
daten.set('titel', wert)
daten.set('typ', typ)
starte(async () => {
const antwort = await schnellErfassen(daten)
if (antwort.ok) {
setTitel('')
toast.success(antwort.meldung)
router.refresh()
// Gleich weiter erfassen können, ohne noch einmal zu klicken.
feld.current?.focus()
} else {
toast.error(antwort.fehler)
}
})
}
return (
<form onSubmit={absenden} className="flex gap-2">
<Input
ref={feld}
value={titel}
onChange={(e) => setTitel(e.target.value)}
placeholder={typ === 'IDEE' ? 'Idee notieren …' : 'Was ist zu tun?'}
maxLength={200}
disabled={laeuft}
aria-label={typ === 'IDEE' ? 'Neue Idee' : 'Neue Aufgabe'}
/>
<Button type="submit" disabled={laeuft} className="shrink-0">
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : typ === 'IDEE' ? (
<Lightbulb aria-hidden />
) : (
<Plus aria-hidden />
)}
Erfassen
</Button>
</form>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils'
/**
* Fortschritt als Balken. Der Wert steht zusätzlich als Text daneben — ein
* Balken allein ist für Menschen mit Sehschwäche und für Vorleseprogramme
* wertlos.
*/
export function Fortschrittsbalken({
prozent,
className,
beschriftung,
}: {
prozent: number
className?: string
beschriftung?: string
}) {
const wert = Math.min(100, Math.max(0, Math.round(prozent)))
return (
<div
role="progressbar"
aria-valuenow={wert}
aria-valuemin={0}
aria-valuemax={100}
{...(beschriftung ? { 'aria-label': beschriftung } : {})}
className={cn('bg-gedaempft h-2 w-full overflow-hidden rounded-full', className)}
>
<div
className={cn(
'h-full rounded-full transition-[width]',
wert === 100 ? 'bg-erfolg' : 'bg-betont',
)}
style={{ width: `${wert}%` }}
/>
</div>
)
}
+23 -8
View File
@@ -10,26 +10,33 @@ import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean }
const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
export function Kopfleiste({
name,
email,
rolle,
instanzName,
inbox = 0,
}: {
name: string
email: string
rolle: Rolle
instanzName: string
/** Anzahl noch nicht einsortierter Aufgaben. */
inbox?: number
}) {
const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
const pfad = usePathname()
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
@@ -68,6 +75,14 @@ export function Kopfleiste({
<Shield className="mr-1 inline size-3.5" aria-hidden />
)}
{eintrag.name}
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
<span
className="bg-betont text-betont-vordergrund ml-1.5 inline-flex min-w-4 justify-center rounded-full px-1 text-[0.6875rem] leading-4 font-medium"
aria-label={`${eintrag.zaehler} offen`}
>
{eintrag.zaehler}
</span>
)}
</Link>
)
})}
+28
View File
@@ -0,0 +1,28 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const kennzeichenStile = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap',
{
variants: {
art: {
neutral: 'bg-gedaempft text-gedaempft-vordergrund',
betont: 'bg-betont/10 text-betont',
erfolg: 'bg-erfolg/15 text-erfolg',
warnung: 'bg-warnung/20 text-warnung',
gefahr: 'bg-gefahr/15 text-gefahr',
},
},
defaultVariants: { art: 'neutral' },
},
)
export type KennzeichenEigenschaften = React.ComponentProps<'span'> &
VariantProps<typeof kennzeichenStile>
/** Kleine Auszeichnung für Status, Typ oder Fälligkeit. */
function Kennzeichen({ className, art, ...rest }: KennzeichenEigenschaften) {
return <span className={cn(kennzeichenStile({ art }), className)} {...rest} />
}
export { Kennzeichen }
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
/**
* Bewusst das native <select>: es bedient sich auf dem Handy mit dem
* Systemdialog, ist ohne Zusatzcode tastaturbedienbar und braucht kein
* JavaScript, damit ein Formular abgeschickt werden kann.
*/
function Select({ className, children, ...rest }: React.ComponentProps<'select'>) {
return (
<select
className={cn(
'border-eingabe bg-karte h-9 w-full rounded-md border px-3 text-sm shadow-xs transition-colors outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-2',
'aria-invalid:border-gefahr aria-invalid:ring-gefahr/30',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...rest}
>
{children}
</select>
)
}
export { Select }
+18
View File
@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils'
function Textarea({ className, ...rest }: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn(
'border-eingabe bg-karte min-h-20 w-full rounded-md border px-3 py-2 text-sm shadow-xs transition-colors outline-none',
'placeholder:text-gedaempft-vordergrund',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...rest}
/>
)
}
export { Textarea }