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>
264 lines
8.0 KiB
TypeScript
264 lines
8.0 KiB
TypeScript
'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>
|
|
)
|
|
}
|