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
+157
View File
@@ -0,0 +1,157 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import { X } from 'lucide-react'
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 { STATUS_NAME, TYP_NAME } from '@/lib/aufgabe'
type Eigenschaften = {
bereiche: { id: string; name: string }[]
projekte: { id: string; name: string }[]
}
const STATUS = ['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'] as const
const TYPEN = ['AUFGABE', 'ARBEITSPAKET', 'MEILENSTEIN', 'IDEE', 'SPIEGEL'] as const
/**
* Die Filter stehen in der Adresse, nicht im Zustand der Komponente. So lässt
* sich eine gefilterte Liste als Lesezeichen ablegen und teilen, und der
* Zurück-Knopf tut, was er soll.
*/
export function Filterleiste({ bereiche, projekte }: Eigenschaften) {
const router = useRouter()
const parameter = useSearchParams()
const [laeuft, starte] = useTransition()
function setzen(schluessel: string, wert: string) {
const neu = new URLSearchParams(parameter.toString())
if (wert) neu.set(schluessel, wert)
else neu.delete(schluessel)
starte(() => router.push(`/aufgaben?${neu.toString()}`))
}
const gesetzt = ['bereich', 'projekt', 'status', 'typ', 'suche'].filter((s) =>
parameter.get(s),
)
return (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1">
<Label htmlFor="filter-suche" className="text-xs font-normal">
Suche
</Label>
<Input
id="filter-suche"
type="search"
className="h-8"
defaultValue={parameter.get('suche') ?? ''}
placeholder="Titel oder Notiz"
disabled={laeuft}
onKeyDown={(e) => {
if (e.key === 'Enter') setzen('suche', e.currentTarget.value.trim())
}}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-bereich" className="text-xs font-normal">
Bereich
</Label>
<Select
id="filter-bereich"
className="h-8 w-40"
value={parameter.get('bereich') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('bereich', e.target.value)}
>
<option value="">Alle</option>
<option value="inbox">Nur Inbox</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-status" className="text-xs font-normal">
Status
</Label>
<Select
id="filter-status"
className="h-8 w-36"
value={parameter.get('status') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('status', e.target.value)}
>
<option value="">Nur offene</option>
<option value="alle">Alle</option>
{STATUS.map((status) => (
<option key={status} value={status}>
{STATUS_NAME[status]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-typ" className="text-xs font-normal">
Art
</Label>
<Select
id="filter-typ"
className="h-8 w-36"
value={parameter.get('typ') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('typ', e.target.value)}
>
<option value="">Alle</option>
{TYPEN.map((typ) => (
<option key={typ} value={typ}>
{TYP_NAME[typ]}
</option>
))}
</Select>
</div>
{projekte.length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="filter-projekt" className="text-xs font-normal">
Projekt
</Label>
<Select
id="filter-projekt"
className="h-8 w-44"
value={parameter.get('projekt') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('projekt', e.target.value)}
>
<option value="">Alle</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</Select>
</div>
)}
{gesetzt.length > 0 && (
<Button
art="leise"
groesse="klein"
disabled={laeuft}
onClick={() => starte(() => router.push('/aufgaben'))}
>
<X aria-hidden />
Filter zurücksetzen
</Button>
)}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { Plus, X } from 'lucide-react'
import { AufgabeFormular } from '@/components/aufgaben/aufgabe-formular'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
type Eigenschaften = {
bereiche: { id: string; name: string }[]
projekte: { id: string; name: string }[]
vorgabe?: { bereichId?: string; projektId?: string; typ?: string }
beschriftung?: string
}
/** Klappt das vollständige Formular auf; im Normalfall genügt die Inbox. */
export function NeueAufgabe({
bereiche,
projekte,
vorgabe,
beschriftung = 'Neue Aufgabe',
}: Eigenschaften) {
const [offen, setOffen] = useState(false)
if (!offen) {
return (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
{beschriftung}
</Button>
)
}
return (
<Card className="w-full">
<CardKopf className="flex-row items-center justify-between">
<CardTitel className="text-base">{beschriftung}</CardTitel>
<Button art="leise" groesse="quadrat" onClick={() => setOffen(false)}>
<X aria-hidden />
<span className="sr-only">Schließen</span>
</Button>
</CardKopf>
<CardInhalt>
<AufgabeFormular
bereiche={bereiche}
projekte={projekte}
{...(vorgabe ? { vorgabe } : {})}
onFertig={() => setOffen(false)}
/>
</CardInhalt>
</Card>
)
}
+110
View File
@@ -0,0 +1,110 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { Filterleiste } from '@/app/(app)/aufgaben/filterleiste'
import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Card, CardInhalt } from '@/components/ui/card'
import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums'
import { aufwandSumme } from '@/lib/aufgabe'
import { alsDauer } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
type AufgabenFilter,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Aufgaben' }
const STATUS_WERTE: readonly string[] = [
'OFFEN',
'GEPLANT',
'WARTET',
'ERLEDIGT',
'VERWORFEN',
]
const TYP_WERTE: readonly string[] = [
'AUFGABE',
'IDEE',
'ARBEITSPAKET',
'MEILENSTEIN',
'SPIEGEL',
]
/** Übersetzt die Adresszeile in einen Filter; Unbekanntes wird ignoriert. */
function filterAus(
parameter: Record<string, string | string[] | undefined>,
): AufgabenFilter {
const einer = (name: string): string | undefined => {
const wert = parameter[name]
return typeof wert === 'string' && wert.length > 0 ? wert : undefined
}
const bereich = einer('bereich')
const status = einer('status')
const typ = einer('typ')
return {
...(bereich === 'inbox' ? { nurInbox: true } : bereich ? { bereichId: bereich } : {}),
...(einer('projekt') ? { projektId: einer('projekt') } : {}),
// Ohne Angabe zeigt die Liste nur, was noch Arbeit bedeutet.
...(status === 'alle'
? {}
: status && STATUS_WERTE.includes(status)
? { status: status as AufgabenStatus }
: { nurOffen: true }),
...(typ && TYP_WERTE.includes(typ) ? { typ: typ as AufgabenTyp } : {}),
...(einer('suche') ? { suche: einer('suche') } : {}),
}
}
export default async function AufgabenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
await verlangeAnmeldung('/aufgaben')
const parameter = await searchParams
const [aufgaben, bereiche, projekte] = await Promise.all([
aufgabenLaden(filterAus(parameter)),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
const geschaetzt = aufwandSumme(aufgaben)
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Aufgaben</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{aufgaben.length === 0
? 'Nichts gefunden.'
: `${aufgaben.length} Aufgabe(n)${geschaetzt > 0 ? `, zusammen ${alsDauer(geschaetzt)} geschätzt` : ''}.`}
</p>
</div>
<NeueAufgabe bereiche={bereiche} projekte={projekte} />
</header>
<Card>
<CardInhalt className="py-4">
<Suspense
fallback={<p className="text-gedaempft-vordergrund text-sm">Filter …</p>}
>
<Filterleiste bereiche={bereiche} projekte={projekte} />
</Suspense>
</CardInhalt>
</Card>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
leerText="Keine Aufgabe passt zu diesen Filtern."
/>
</div>
)
}