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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Kopfleiste } from '@/components/kopfleiste'
|
||||
import { zaehlstaende } from '@/server/abfragen/aufgaben'
|
||||
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||
import { versionAnzeige } from '@/lib/version'
|
||||
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
|
||||
@@ -18,6 +19,7 @@ export default async function AngemeldetesLayout({
|
||||
// Anmeldung dort weitergeht, wo man hinwollte.
|
||||
const benutzer = await verlangeAnmeldung(await angefragterPfad())
|
||||
const instanz = aktuelleInstanz()
|
||||
const zahlen = await zaehlstaende()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({
|
||||
email={benutzer.email}
|
||||
rolle={benutzer.rolle}
|
||||
instanzName={INSTANZ_NAME[instanz]}
|
||||
inbox={zahlen.inbox}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||
|
||||
+112
-35
@@ -1,4 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
@@ -8,6 +10,12 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { wochenKapazitaet } from '@/lib/bereich'
|
||||
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||
import {
|
||||
aufgabenLaden,
|
||||
bereicheLaden,
|
||||
projekteZurAuswahlLaden,
|
||||
zaehlstaende,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
@@ -16,10 +24,23 @@ export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const heute = jetzt()
|
||||
|
||||
const bereiche = await db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true }, 200),
|
||||
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
|
||||
|
||||
@@ -30,10 +51,53 @@ export default async function StartSeite() {
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||
<Card>
|
||||
<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>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||
Noch keine Bereiche angelegt.{' '}
|
||||
@@ -44,38 +108,51 @@ export default async function StartSeite() {
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereiche.map((bereich) => (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
{bereichsListe.map((bereich) => {
|
||||
const offeneHier = alleOffenen.filter(
|
||||
(a) => a.bereich?.id === bereich.id && a.typ !== 'IDEE',
|
||||
)
|
||||
return (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{offeneHier.length === 0
|
||||
? 'Nichts offen'
|
||||
: `${offeneHier.length} offen`}{' '}
|
||||
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
</Card>
|
||||
{zahlen.ideen > 0 && (
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">
|
||||
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
|
||||
{zahlen.ideen} Idee(n) warten
|
||||
</Link>
|
||||
</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user