diff --git a/src/app/(app)/aufgaben/filterleiste.tsx b/src/app/(app)/aufgaben/filterleiste.tsx new file mode 100644 index 0000000..61bc73c --- /dev/null +++ b/src/app/(app)/aufgaben/filterleiste.tsx @@ -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 ( +
+
+ + { + 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}

} +
+ +
+ +