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:
co-authored by
Claude Opus 5
parent
b3ca1c9ce7
commit
a4a889fcb8
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user