diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f3fd4..34d2f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +## [0.2.0] – 2026-09-10 + +### Hinzugefügt + +- Aufgaben mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft; + Projekte als Klammer darum. +- Schnellerfassung mit einem einzigen Feld auf der Startseite und in der Inbox. +- Inbox: alles ohne Bereich sammelt sich dort und wird später einsortiert. + Die Zahl im Menü zeigt, wie viel noch liegt. +- Aufgabenliste mit Filtern nach Suche, Bereich, Status, Art und Projekt. + Die Filter stehen in der Adresse und lassen sich als Lesezeichen ablegen. +- Ideen als eigene Ansicht, ohne Termin und ohne Aufwand; per Klick wird + daraus eine Aufgabe. +- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt. +- Die Startseite zeigt, was überfällig, heute oder morgen fällig ist. +- Handbuchseiten zu Aufgaben, Ideen und Projekten. + +### Geändert + +- Die Startseite zählt nicht mehr nur Bereiche auf, sondern stellt voran, + was jetzt dran ist. + +### Sicherheit + +- Statuswechsel folgen festen Übergängen: Abgeschlossenes wird erst wieder + geöffnet. Ein abweichender Wert im Formular führt zu einer Meldung, nicht + zu einem stillen Zustandssprung. +- Angegebene Bereiche und Projekte werden vor dem Speichern geprüft; ein + erfundener Verweis gibt eine Meldung statt eines Datenbankfehlers. + ## [0.1.0] – 2026-09-10 ### Hinzugefügt diff --git a/README.md b/README.md index 71244c4..d4345ea 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben wird. -> **Stand:** Meilenstein 1 von 8. Die Grundlage steht: Anmeldung, Rollen, -> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der -> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt, -> was wann folgt. +> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben, +> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb. +> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen +> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt. - **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone` zur ersten Anmeldung @@ -22,6 +22,12 @@ wird. ### Heute +- **Schnellerfassung** mit einem einzigen Feld; alles ohne Bereich sammelt + sich in der **Inbox** und wird später einsortiert +- **Aufgaben** mit Typ, Status, Priorität, Aufwand und Fälligkeit, sortiert + nach Dringlichkeit und filterbar über die Adresszeile +- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar +- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird @@ -32,9 +38,9 @@ wird. ### Geplant -Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen -und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und -ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). +Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen, +Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein +MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). ## Bildergalerie @@ -42,6 +48,10 @@ ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). | ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | +| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | +| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | +| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | +| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | diff --git a/ROADMAP.md b/ROADMAP.md index 5d04e00..b434203 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. | Meilenstein | Ziel | Status | | --------------------------------------- | ---------------------------- | -------- | | [M1](#m1--fundament) | Fundament | erledigt | -| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant | +| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | geplant | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | @@ -45,15 +45,21 @@ wirklich braucht, statt heute geraten. ## M2 – Aufgaben und Projekte +**Erledigt am 10.09.2026 mit Version 0.2.0.** + **Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender. -- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle -- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten -- [ ] Inbox: schnell erfassen, später einsortieren -- [ ] Ideen getrennt von Aufgaben -- [ ] Projekte mit Arbeitspaketen und Meilensteinen -- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit -- [ ] Handbuchseiten und Bilder +- [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle +- [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten +- [x] Inbox: schnell erfassen, später einsortieren +- [x] Ideen getrennt von Aufgaben +- [x] Projekte mit Arbeitspaketen und Meilensteinen +- [x] Listen mit Filtern nach Bereich, Status und Fälligkeit +- [x] Handbuchseiten und Bilder + +**Bewusst offen gelassen:** Kein Wiederholungsrhythmus für Aufgaben und keine +Verknüpfung von Aufgaben untereinander — beides steht unter „Ideen für +später" und wartet darauf, ob es im Alltag wirklich fehlt. ## M3 – Kalender lesen diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index c47687a..5656970 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -8,7 +8,98 @@ die Übersicht aus `scripts/zeit.sh summe`. | Meilenstein | Art | Minuten | Stunden | | ----------- | ------ | ------- | -------- | | M1 | claude | 161 | 2,68 | -| **Gesamt** | | **161** | **2,68** | +| M2 | claude | 44 | 0,73 | +| **Gesamt** | | **205** | **3,42** | + +--- + +## 2026-09-10 – M2: Aufgaben und Projekte + +**Dauer:** 44 Minuten (15:57–16:41), Art `claude`. + +**Ziel der Sitzung:** Erfassen und Wiederfinden — Aufgaben, Inbox, Ideen und +Projekte. Noch ohne Kalender. + +### Erledigt + +- Datenmodell `Aufgabe` und `Projekt` samt Migration +- Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern +- Ideen als eigene Ansicht, mit Übernahme in eine Aufgabe +- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt +- Startseite zeigt jetzt, was überfällig oder demnächst fällig ist +- 53 neue Unit-Tests, 11 neue Integrationsprüfungen, 4 neue E2E-Tests +- Handbuchseiten zu Aufgaben, Ideen und Projekten; Bilder neu aufgenommen + +### Entscheidungen mit Begründung + +**Die Inbox ist kein eigener Zustand.** Eine Aufgabe liegt genau dann in der +Inbox, wenn sie keinen Bereich hat. Ein zusätzliches Feld „einsortiert" +hätte mit der Zuordnung auseinanderlaufen können; so ist der Zustand nicht +darstellbar. + +**Projekt als eigenes Modell, nicht als Aufgabentyp.** Ein Projekt hat +selbst weder Aufwand noch Fälligkeit — beides ergibt sich aus dem Inhalt. +`ARBEITSPAKET` und `MEILENSTEIN` bleiben Typen der Aufgaben _innerhalb_ +eines Projekts. + +**Verworfene Aufgaben zählen gar nicht in den Fortschritt.** Weder als +erledigt noch als offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte +gestrichen wurde — eine Zahl, die lügt, ist schlechter als keine. + +**Sortiert wird in der Anwendung, nicht in SQL.** Die Reihenfolge ist +mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann Titel +mit deutscher Sortierung). Als geprüfte reine Funktion steht sie an einer +Stelle; eine zweite Fassung in SQL wäre eine Fehlerquelle. Bei den erwarteten +Mengen ist das unkritisch — sollte es einmal eng werden, ist der Ort für die +Änderung klar. + +**Filter stehen in der Adresse.** Eine gefilterte Liste lässt sich als +Lesezeichen ablegen, und der Zurück-Knopf tut, was er soll. + +**Statuswechsel folgen festen Übergängen.** Aus `ERLEDIGT` und `VERWORFEN` +geht es nur zurück nach `OFFEN`. Wer per Formular etwas anderes einreicht, +bekommt eine Meldung — nicht einen stillen Zustandssprung. + +**Fälligkeit in Kalendertagen, nicht in Stunden.** Was heute um 23:00 fällig +ist, ist heute fällig. Alles andere fühlt sich beim Benutzen falsch an. + +**Natives ` { + 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..ee47777 --- /dev/null +++ b/src/app/(app)/aufgaben/page.tsx @@ -0,0 +1,112 @@ +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') } : {}), + // Ideen haben ihre eigene Seite; wer sie hier sehen will, filtert danach. + ohneIdeen: true, + } +} + +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..f7954c7 --- /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, ohneIdeen: 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..164dcd7 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,21 @@ 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, ohneIdeen: 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. + const dran = alleOffenen.filter( + (a) => a.ueberfaellig || a.faelligkeit === 'heute' || a.faelligkeit === 'morgen', + ) const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email @@ -30,10 +49,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 +106,49 @@ 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) + 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}

} +
+ +
+ +