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>
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from 'next'
import { Lightbulb } from 'lucide-react'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Ideen' }
export default async function IdeenSeite() {
await verlangeAnmeldung('/ideen')
const [ideen, bereiche, projekte] = await Promise.all([
aufgabenLaden({ typ: 'IDEE', nurOffen: true }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Lightbulb className="size-6" aria-hidden />
Ideen
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Was irgendwann einmal lohnen könnte. Ideen haben bewusst keinen Termin und
drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
</p>
</header>
<Card>
<CardKopf>
<CardTitel className="text-base">Idee notieren</CardTitel>
<CardBeschreibung>
Festhalten, bevor sie weg ist. Bewerten kannst du später.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung typ="IDEE" />
</CardInhalt>
</Card>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-medium">
Gesammelt
{ideen.length > 0 && (
<span className="text-gedaempft-vordergrund ml-2 text-sm font-normal">
{ideen.length}
</span>
)}
</h2>
<AufgabenListe
aufgaben={ideen}
bereiche={bereiche}
projekte={projekte}
mitUebernehmen
leerText="Noch keine Idee notiert."
/>
</section>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from 'next'
import { Inbox } from 'lucide-react'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Inbox' }
export default async function InboxSeite() {
await verlangeAnmeldung('/inbox')
const [aufgaben, bereiche, projekte] = await Promise.all([
aufgabenLaden({ nurInbox: true, nurOffen: true }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Inbox className="size-6" aria-hidden />
Inbox
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Alles, was noch keinem Bereich zugeordnet ist. Erst erfassen, später
einsortieren — Nachdenken beim Aufschreiben kostet nur Zeit.
</p>
</header>
<Card>
<CardKopf>
<CardTitel className="text-base">Schnell erfassen</CardTitel>
<CardBeschreibung>
Ein Titel genügt. Bereich, Termin und Aufwand kommen beim Einsortieren.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung />
</CardInhalt>
</Card>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-medium">
Noch einzusortieren
{aufgaben.length > 0 && (
<span className="text-gedaempft-vordergrund ml-2 text-sm font-normal">
{aufgaben.length}
</span>
)}
</h2>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
mitEinsortieren
leerText="Die Inbox ist leer. Alles einsortiert."
/>
</section>
</div>
)
}
+3
View File
@@ -1,4 +1,5 @@
import { Kopfleiste } from '@/components/kopfleiste' import { Kopfleiste } from '@/components/kopfleiste'
import { zaehlstaende } from '@/server/abfragen/aufgaben'
import { INSTANZ_NAME } from '@/lib/instanz' import { INSTANZ_NAME } from '@/lib/instanz'
import { versionAnzeige } from '@/lib/version' import { versionAnzeige } from '@/lib/version'
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung' import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
@@ -18,6 +19,7 @@ export default async function AngemeldetesLayout({
// Anmeldung dort weitergeht, wo man hinwollte. // Anmeldung dort weitergeht, wo man hinwollte.
const benutzer = await verlangeAnmeldung(await angefragterPfad()) const benutzer = await verlangeAnmeldung(await angefragterPfad())
const instanz = aktuelleInstanz() const instanz = aktuelleInstanz()
const zahlen = await zaehlstaende()
return ( return (
<div className="flex min-h-dvh flex-col"> <div className="flex min-h-dvh flex-col">
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({
email={benutzer.email} email={benutzer.email}
rolle={benutzer.rolle} rolle={benutzer.rolle}
instanzName={INSTANZ_NAME[instanz]} instanzName={INSTANZ_NAME[instanz]}
inbox={zahlen.inbox}
/> />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
+112 -35
View File
@@ -1,4 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import { import {
Card, Card,
CardBeschreibung, CardBeschreibung,
@@ -8,6 +10,12 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { wochenKapazitaet } from '@/lib/bereich' import { wochenKapazitaet } from '@/lib/bereich'
import { alsDatum, alsDauer } from '@/lib/zeit' import { alsDatum, alsDauer } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
zaehlstaende,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung' import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
@@ -16,10 +24,23 @@ export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/') const benutzer = await verlangeAnmeldung('/')
const heute = jetzt() const heute = jetzt()
const bereiche = await db.bereich.findMany({ const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
where: { aktiv: true }, aufgabenLaden({ nurOffen: true }, 200),
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], bereicheLaden(),
}) projekteZurAuswahlLaden(),
zaehlstaende(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
])
// Was jetzt zählt: überfällig, heute oder morgen fällig. Ideen bleiben draußen.
const dran = alleOffenen.filter(
(a) =>
a.typ !== 'IDEE' &&
(a.ueberfaellig || a.faelligkeit === 'heute' || a.faelligkeit === 'morgen'),
)
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
@@ -30,10 +51,53 @@ export default async function StartSeite() {
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p> <p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
</header> </header>
<section className="flex flex-col gap-4"> <Card>
<h2 className="text-lg font-medium">Deine Bereiche</h2> <CardKopf>
<CardTitel className="text-base">Schnell erfassen</CardTitel>
<CardBeschreibung>
Landet in der{' '}
<Link href="/inbox" className="text-betont underline underline-offset-4">
Inbox
</Link>{' '}
und wird später einsortiert.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung />
</CardInhalt>
</Card>
{bereiche.length === 0 ? ( <section className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-medium">Jetzt dran</h2>
<Link
href="/aufgaben"
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
>
Alle {zahlen.offen} offenen Aufgaben
</Link>
</div>
<AufgabenListe
aufgaben={dran}
bereiche={bereiche}
projekte={projekte}
leerText="Nichts überfällig, nichts heute fällig. Ein guter Tag."
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-medium">Deine Bereiche</h2>
<Link
href="/bereiche"
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
>
Bereiche verwalten
</Link>
</div>
{bereichsListe.length === 0 ? (
<Card> <Card>
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm"> <CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
Noch keine Bereiche angelegt.{' '} Noch keine Bereiche angelegt.{' '}
@@ -44,38 +108,51 @@ export default async function StartSeite() {
</Card> </Card>
) : ( ) : (
<ul className="grid gap-4 sm:grid-cols-2"> <ul className="grid gap-4 sm:grid-cols-2">
{bereiche.map((bereich) => ( {bereichsListe.map((bereich) => {
<li key={bereich.id}> const offeneHier = alleOffenen.filter(
<Card> (a) => a.bereich?.id === bereich.id && a.typ !== 'IDEE',
<CardKopf className="flex-row items-center gap-3"> )
<span return (
aria-hidden <li key={bereich.id}>
className="size-3 shrink-0 rounded-full ring-1 ring-black/10" <Card>
style={{ backgroundColor: bereich.farbe }} <CardKopf className="flex-row items-center gap-3">
/> <span
<CardTitel className="text-base">{bereich.name}</CardTitel> aria-hidden
</CardKopf> className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
<CardInhalt> style={{ backgroundColor: bereich.farbe }}
<p className="text-gedaempft-vordergrund text-sm"> />
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant <CardTitel className="text-base">{bereich.name}</CardTitel>
</p> </CardKopf>
</CardInhalt> <CardInhalt>
</Card> <p className="text-gedaempft-vordergrund text-sm">
</li> {offeneHier.length === 0
))} ? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p>
</CardInhalt>
</Card>
</li>
)
})}
</ul> </ul>
)} )}
</section> </section>
<Card> {zahlen.ideen > 0 && (
<CardKopf> <Card>
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel> <CardKopf>
<CardBeschreibung> <CardTitel className="text-base">
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die <Link href="/ideen" className="hover:underline hover:underline-offset-4">
Wochenplanung per Ziehen und Ablegen mit M4. {zahlen.ideen} Idee(n) warten
</CardBeschreibung> </Link>
</CardKopf> </CardTitel>
</Card> <CardBeschreibung>
Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
</CardBeschreibung>
</CardKopf>
</Card>
)}
</div> </div>
) )
} }
+153
View File
@@ -0,0 +1,153 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe'
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
import { alsDatum, alsDauer, alsTagesschluessel } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const projekt = await db.projekt.findUnique({
where: { id },
select: { name: true },
})
return { title: projekt?.name ?? 'Projekt' }
}
export default async function ProjektSeite({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
await verlangeAnmeldung(`/projekte/${id}`)
const projekt = await db.projekt.findUnique({
where: { id },
select: {
id: true,
name: true,
beschreibung: true,
status: true,
zielAm: true,
bereichId: true,
bereich: { select: { id: true, name: true, farbe: true } },
},
})
if (!projekt) notFound()
const [aufgaben, bereiche, projekte] = await Promise.all([
// Hier bewusst ohne nurOffen: im Projekt gehört auch das Erledigte ins Bild.
aufgabenLaden({ projektId: projekt.id }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
const fortschritt = fortschrittBerechnen(aufgaben)
const offenerAufwand = aufwandSumme(
aufgaben.filter((a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN'),
)
return (
<div className="flex flex-col gap-6">
<Link
href="/projekte"
className="text-gedaempft-vordergrund hover:text-vordergrund inline-flex w-fit items-center gap-1 text-sm"
>
<ArrowLeft className="size-4" aria-hidden />
Alle Projekte
</Link>
<header className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{projekt.bereich && (
<span
aria-hidden
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: projekt.bereich.farbe }}
/>
)}
<h1 className="text-2xl font-semibold tracking-tight">{projekt.name}</h1>
{projekt.status !== 'AKTIV' && (
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
)}
</div>
{projekt.beschreibung && (
<p className="text-gedaempft-vordergrund text-sm whitespace-pre-line">
{projekt.beschreibung}
</p>
)}
<Fortschrittsbalken
prozent={fortschritt.prozent}
beschriftung={`Fortschritt von ${projekt.name}`}
/>
<p className="text-gedaempft-vordergrund text-sm">
{fortschritt.gesamt === 0
? 'Noch keine Aufgaben zugeordnet.'
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)} geschätzt`}
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
</p>
</header>
<div className="flex flex-wrap items-start justify-between gap-4">
<h2 className="text-lg font-medium">Aufgaben im Projekt</h2>
<NeueAufgabe
bereiche={bereiche}
projekte={projekte}
vorgabe={{
projektId: projekt.id,
...(projekt.bereichId ? { bereichId: projekt.bereichId } : {}),
typ: 'ARBEITSPAKET',
}}
beschriftung="Aufgabe hinzufügen"
/>
</div>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
leerText="Diesem Projekt ist noch keine Aufgabe zugeordnet."
/>
<Card>
<CardKopf>
<CardTitel className="text-base">Projekt bearbeiten</CardTitel>
</CardKopf>
<CardInhalt>
<ProjektFormular
projekt={{
id: projekt.id,
name: projekt.name,
beschreibung: projekt.beschreibung,
status: projekt.status,
bereichId: projekt.bereichId,
zielWert: projekt.zielAm ? alsTagesschluessel(projekt.zielAm) : '',
}}
bereiche={bereiche}
/>
</CardInhalt>
</Card>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
import { alsDatum, alsDauer } from '@/lib/zeit'
import { bereicheLaden } from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export const metadata: Metadata = { title: 'Projekte' }
export default async function ProjekteSeite() {
await verlangeAnmeldung('/projekte')
const [projekte, bereiche] = await Promise.all([
db.projekt.findMany({
orderBy: [{ status: 'asc' }, { name: 'asc' }],
select: {
id: true,
name: true,
beschreibung: true,
status: true,
zielAm: true,
bereich: { select: { id: true, name: true, farbe: true } },
aufgaben: { select: { status: true, aufwandMinuten: true } },
},
}),
bereicheLaden(),
])
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">Projekte</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Eine Klammer um mehrere Aufgaben. Aufwand und Fortschritt ergeben sich aus
dem, was drinsteckt.
</p>
</div>
<ProjektFormular bereiche={bereiche} einklappbar />
</header>
{projekte.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Projekt angelegt.
</CardInhalt>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{projekte.map((projekt) => {
const fortschritt = fortschrittBerechnen(projekt.aufgaben)
const offenerAufwand = aufwandSumme(
projekt.aufgaben.filter(
(a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN',
),
)
return (
<li key={projekt.id}>
<Card className="h-full">
<CardKopf>
<div className="flex flex-wrap items-center gap-2">
{projekt.bereich && (
<span
aria-hidden
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: projekt.bereich.farbe }}
/>
)}
<CardTitel className="text-base">
<Link
href={`/projekte/${projekt.id}`}
className="hover:underline hover:underline-offset-4"
>
{projekt.name}
</Link>
</CardTitel>
{projekt.status !== 'AKTIV' && (
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
)}
</div>
{projekt.beschreibung && (
<p className="text-gedaempft-vordergrund line-clamp-2 text-sm">
{projekt.beschreibung}
</p>
)}
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
<Fortschrittsbalken
prozent={fortschritt.prozent}
beschriftung={`Fortschritt von ${projekt.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{fortschritt.gesamt === 0
? 'Noch keine Aufgaben zugeordnet.'
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)}`}
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
</p>
</CardInhalt>
</Card>
</li>
)
})}
</ul>
)}
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
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 { PROJEKT_STATUS_NAME } from '@/lib/aufgabe'
import {
projektAendern,
projektAnlegen,
projektLoeschen,
} from '@/server/aktionen/projekte'
export type ProjektWerte = {
id: string
name: string
beschreibung: string | null
status: 'AKTIV' | 'PAUSIERT' | 'ABGESCHLOSSEN' | 'VERWORFEN'
bereichId: string | null
zielWert: string
}
const STATUS = ['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'] as const
type Eigenschaften = {
projekt?: ProjektWerte
bereiche: { id: string; name: string }[]
/** Beim Anlegen: Formular erst auf Klick zeigen. */
einklappbar?: boolean
onFertig?: () => void
}
export function ProjektFormular({
projekt,
bereiche,
einklappbar = false,
onFertig,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [offen, setOffen] = useState(!einklappbar)
const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false)
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault()
const formular = ereignis.currentTarget
const daten = new FormData(formular)
starte(async () => {
const antwort = projekt
? await projektAendern(projekt.id, daten)
: await projektAnlegen(daten)
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
if (!projekt) {
formular.reset()
if (einklappbar) setOffen(false)
}
router.refresh()
onFertig?.()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
function loeschen() {
if (!projekt) return
if (!loeschenBestaetigt) {
setLoeschenBestaetigt(true)
return
}
const daten = new FormData()
daten.set('id', projekt.id)
starte(async () => {
const antwort = await projektLoeschen(daten)
if (antwort.ok) {
toast.success(antwort.meldung)
router.push('/projekte')
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
if (einklappbar && !offen) {
return (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Neues Projekt
</Button>
)
}
const kennung = projekt?.id ?? 'neu'
const formular = (
<form onSubmit={absenden} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`name-${kennung}`}>Name</Label>
<Input
id={`name-${kennung}`}
name="name"
defaultValue={projekt?.name ?? ''}
required
maxLength={80}
aria-invalid={Boolean(felder.name)}
/>
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`beschreibung-${kennung}`}>Beschreibung</Label>
<Textarea
id={`beschreibung-${kennung}`}
name="beschreibung"
defaultValue={projekt?.beschreibung ?? ''}
maxLength={2000}
placeholder="Worum geht es, und woran merkst du, dass es fertig ist?"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`status-${kennung}`}>Status</Label>
<Select
id={`status-${kennung}`}
name="status"
defaultValue={projekt?.status ?? 'AKTIV'}
>
{STATUS.map((status) => (
<option key={status} value={status}>
{PROJEKT_STATUS_NAME[status]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`bereich-${kennung}`}>Bereich</Label>
<Select
id={`bereich-${kennung}`}
name="bereichId"
defaultValue={projekt?.bereichId ?? ''}
>
<option value="">Kein Bereich</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`ziel-${kennung}`}>Zieltermin</Label>
<Input
id={`ziel-${kennung}`}
name="zielAm"
type="date"
defaultValue={projekt?.zielWert ?? ''}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
{projekt ? 'Speichern' : 'Projekt anlegen'}
</Button>
{(onFertig ?? einklappbar) && (
<Button
type="button"
art="leise"
disabled={laeuft}
onClick={() => {
if (einklappbar) setOffen(false)
onFertig?.()
}}
>
Abbrechen
</Button>
)}
{projekt && (
<Button
type="button"
art="gefahr"
className="ml-auto"
disabled={laeuft}
onClick={loeschen}
onBlur={() => setLoeschenBestaetigt(false)}
>
<Trash2 aria-hidden />
{loeschenBestaetigt ? 'Wirklich löschen?' : 'Löschen'}
</Button>
)}
</div>
{projekt && loeschenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die enthaltenen Aufgaben bleiben erhalten und verlieren nur ihre Zuordnung.
</p>
)}
</form>
)
if (!einklappbar) return formular
return (
<Card className="w-full">
<CardKopf className="flex-row items-center justify-between">
<CardTitel className="text-base">Neues Projekt</CardTitel>
<Button art="leise" groesse="quadrat" onClick={() => setOffen(false)}>
<X aria-hidden />
<span className="sr-only">Schließen</span>
</Button>
</CardKopf>
<CardInhalt>{formular}</CardInhalt>
</Card>
)
}
@@ -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 { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean } type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
export function Kopfleiste({ export function Kopfleiste({
name, name,
email, email,
rolle, rolle,
instanzName, instanzName,
inbox = 0,
}: { }: {
name: string name: string
email: string email: string
rolle: Rolle rolle: Rolle
instanzName: string 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 pfad = usePathname()
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle)) 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 /> <Shield className="mr-1 inline size-3.5" aria-hidden />
)} )}
{eintrag.name} {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> </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 }