From a4a889fcb8767a17dc3122375d451f5a603462c5 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 16:17:44 +0200 Subject: [PATCH] feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 { + if (e.key === 'Enter') setzen('suche', e.currentTarget.value.trim()) + }} + /> + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + {projekte.length > 0 && ( +
+ + +
+ )} + + {gesetzt.length > 0 && ( + + )} + + ) +} diff --git a/src/app/(app)/aufgaben/neue-aufgabe.tsx b/src/app/(app)/aufgaben/neue-aufgabe.tsx new file mode 100644 index 0000000..fb4771d --- /dev/null +++ b/src/app/(app)/aufgaben/neue-aufgabe.tsx @@ -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 ( + + ) + } + + return ( + + + {beschriftung} + + + + setOffen(false)} + /> + + + ) +} diff --git a/src/app/(app)/aufgaben/page.tsx b/src/app/(app)/aufgaben/page.tsx new file mode 100644 index 0000000..c23f478 --- /dev/null +++ b/src/app/(app)/aufgaben/page.tsx @@ -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, +): 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> +}) { + await verlangeAnmeldung('/aufgaben') + const parameter = await searchParams + + const [aufgaben, bereiche, projekte] = await Promise.all([ + aufgabenLaden(filterAus(parameter)), + bereicheLaden(), + projekteZurAuswahlLaden(), + ]) + + const geschaetzt = aufwandSumme(aufgaben) + + return ( +
+
+
+

Aufgaben

+

+ {aufgaben.length === 0 + ? 'Nichts gefunden.' + : `${aufgaben.length} Aufgabe(n)${geschaetzt > 0 ? `, zusammen ${alsDauer(geschaetzt)} geschätzt` : ''}.`} +

+
+ +
+ + + + Filter …

} + > + +
+
+
+ + +
+ ) +} diff --git a/src/app/(app)/ideen/page.tsx b/src/app/(app)/ideen/page.tsx new file mode 100644 index 0000000..5a54781 --- /dev/null +++ b/src/app/(app)/ideen/page.tsx @@ -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 ( +
+
+

+ + Ideen +

+

+ Was irgendwann einmal lohnen könnte. Ideen haben bewusst keinen Termin und + drängen sich nicht in die Aufgabenliste — bis du eine übernimmst. +

+
+ + + + Idee notieren + + Festhalten, bevor sie weg ist. Bewerten kannst du später. + + + + + + + +
+

+ Gesammelt + {ideen.length > 0 && ( + + {ideen.length} + + )} +

+ + +
+
+ ) +} diff --git a/src/app/(app)/inbox/page.tsx b/src/app/(app)/inbox/page.tsx new file mode 100644 index 0000000..1706b8c --- /dev/null +++ b/src/app/(app)/inbox/page.tsx @@ -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 ( +
+
+

+ + Inbox +

+

+ Alles, was noch keinem Bereich zugeordnet ist. Erst erfassen, später + einsortieren — Nachdenken beim Aufschreiben kostet nur Zeit. +

+
+ + + + Schnell erfassen + + Ein Titel genügt. Bereich, Termin und Aufwand kommen beim Einsortieren. + + + + + + + +
+

+ Noch einzusortieren + {aufgaben.length > 0 && ( + + {aufgaben.length} + + )} +

+ + +
+
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index b59e22c..07940ff 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 (
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({ email={benutzer.email} rolle={benutzer.rolle} instanzName={INSTANZ_NAME[instanz]} + inbox={zahlen.inbox} />
{children}
diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 5ca34da..87d3385 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -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() {

{alsDatum(heute)}

-
-

Deine Bereiche

+ + + Schnell erfassen + + Landet in der{' '} + + Inbox + {' '} + und wird später einsortiert. + + + + + + - {bereiche.length === 0 ? ( +
+
+

Jetzt dran

+ + Alle {zahlen.offen} offenen Aufgaben + +
+ + +
+ +
+
+

Deine Bereiche

+ + Bereiche verwalten + +
+ + {bereichsListe.length === 0 ? ( Noch keine Bereiche angelegt.{' '} @@ -44,38 +108,51 @@ export default async function StartSeite() { ) : (
    - {bereiche.map((bereich) => ( -
  • - - - - {bereich.name} - - -

    - {alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant -

    -
    -
    -
  • - ))} + {bereichsListe.map((bereich) => { + const offeneHier = alleOffenen.filter( + (a) => a.bereich?.id === bereich.id && a.typ !== 'IDEE', + ) + return ( +
  • + + + + {bereich.name} + + +

    + {offeneHier.length === 0 + ? 'Nichts offen' + : `${offeneHier.length} offen`}{' '} + · {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche +

    +
    +
    +
  • + ) + })}
)}
- - - Was als Nächstes kommt - - Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die - Wochenplanung per Ziehen und Ablegen mit M4. - - - + {zahlen.ideen > 0 && ( + + + + + {zahlen.ideen} Idee(n) warten + + + + Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst. + + + + )}
) } diff --git a/src/app/(app)/projekte/[id]/page.tsx b/src/app/(app)/projekte/[id]/page.tsx new file mode 100644 index 0000000..6808206 --- /dev/null +++ b/src/app/(app)/projekte/[id]/page.tsx @@ -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 { + 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 ( +
+ + + Alle Projekte + + +
+
+ {projekt.bereich && ( + + )} +

{projekt.name}

+ {projekt.status !== 'AKTIV' && ( + {PROJEKT_STATUS_NAME[projekt.status]} + )} +
+ + {projekt.beschreibung && ( +

+ {projekt.beschreibung} +

+ )} + + +

+ {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)}`} +

+
+ +
+

Aufgaben im Projekt

+ +
+ + + + + + Projekt bearbeiten + + + + + +
+ ) +} diff --git a/src/app/(app)/projekte/page.tsx b/src/app/(app)/projekte/page.tsx new file mode 100644 index 0000000..340d6e6 --- /dev/null +++ b/src/app/(app)/projekte/page.tsx @@ -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 ( +
+
+
+

Projekte

+

+ Eine Klammer um mehrere Aufgaben. Aufwand und Fortschritt ergeben sich aus + dem, was drinsteckt. +

+
+ +
+ + {projekte.length === 0 ? ( + + + Noch kein Projekt angelegt. + + + ) : ( +
    + {projekte.map((projekt) => { + const fortschritt = fortschrittBerechnen(projekt.aufgaben) + const offenerAufwand = aufwandSumme( + projekt.aufgaben.filter( + (a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN', + ), + ) + + return ( +
  • + + +
    + {projekt.bereich && ( + + )} + + + {projekt.name} + + + {projekt.status !== 'AKTIV' && ( + {PROJEKT_STATUS_NAME[projekt.status]} + )} +
    + {projekt.beschreibung && ( +

    + {projekt.beschreibung} +

    + )} +
    + + + +

    + {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)}`} +

    +
    +
    +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/app/(app)/projekte/projekt-formular.tsx b/src/app/(app)/projekte/projekt-formular.tsx new file mode 100644 index 0000000..363cb22 --- /dev/null +++ b/src/app/(app)/projekte/projekt-formular.tsx @@ -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>({}) + const [offen, setOffen] = useState(!einklappbar) + const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false) + + function absenden(ereignis: React.FormEvent) { + 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 ( + + ) + } + + const kennung = projekt?.id ?? 'neu' + + const formular = ( +
+
+ + + {felder.name &&

{felder.name}

} +
+ +
+ +