Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04414af00d | ||
|
|
7d1f91e740 | ||
|
|
881fdf0819 | ||
|
|
36370f8ff8 | ||
|
|
d01c49b5a0 | ||
|
|
4d0f83ff51 | ||
|
|
144457b9f0 | ||
|
|
7c8fe10f35 | ||
|
|
a4a889fcb8 | ||
|
|
b3ca1c9ce7 | ||
|
|
63c3c15a3c | ||
|
|
704c490368 | ||
|
|
01ab74f71d | ||
|
|
56e07a40e8 |
@@ -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
|
||||
|
||||
@@ -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 |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
|
||||
@@ -4,9 +4,9 @@ Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
|
||||
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
|
||||
| Meilenstein | Ziel | Status |
|
||||
| --------------------------------------- | ---------------------------- | --------- |
|
||||
| [M1](#m1--fundament) | Fundament | in Arbeit |
|
||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant |
|
||||
| --------------------------------------- | ---------------------------- | -------- |
|
||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||
| [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 |
|
||||
@@ -18,6 +18,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
|
||||
## M1 – Fundament
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.1.0.**
|
||||
|
||||
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
|
||||
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
|
||||
|
||||
@@ -43,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
|
||||
|
||||
|
||||
@@ -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 `<select>` statt einer eigenen Auswahlkomponente.** Es bedient
|
||||
sich auf dem Handy mit dem Systemdialog, ist ohne Zusatzcode
|
||||
tastaturbedienbar und braucht kein JavaScript zum Absenden.
|
||||
|
||||
### Probleme und Lösungen
|
||||
|
||||
| Problem | Lösung |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ESLint beanstandete `String(antwort.meldung)` | Ich hatte die Rückmeldung als `Record<string, unknown>` typisiert, statt den vorhandenen `Rueckmeldung`-Typ zu nehmen. Mit dem richtigen Typ entfällt die Umwandlung ganz. |
|
||||
| Zwei Stellen rechneten die Tagesgrenze mit einem `toLocaleString`-Trick | `src/lib/zeit.ts` konnte das längst sauber. Beide Stellen nutzen jetzt `tagesBeginn()` beziehungsweise `alsTagesschluessel()`; die Tests belegen gleiche Ergebnisse bei weniger Code. |
|
||||
| Typfehler nach der Migration | Der Prisma-Client muss nach jeder Schemaänderung neu erzeugt werden. `npm run pruefen` tut das inzwischen selbst, wenn er fehlt — hier war er nur veraltet. |
|
||||
|
||||
### Eigene Fehler, die ich korrigiert habe
|
||||
|
||||
- Im Demo-Seed stand versehentlich ein kyrillisches Wort als Titel eines
|
||||
Meilensteins. Ersetzt durch „Seite ist online".
|
||||
- Ein Test erwartete, dass 481 Minuten Aufwand erlaubt sind; die Grenze
|
||||
liegt bei 480. Der Test war falsch, nicht der Code — jetzt prüft er die
|
||||
Grenze selbst und den ersten Wert darüber.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Kein Wiederholungsrhythmus für Aufgaben und keine Verknüpfung von Aufgaben
|
||||
untereinander. Beides steht bewusst unter „Ideen für später" und wartet
|
||||
darauf, ob es im Alltag wirklich fehlt.
|
||||
- Die Aufgabenliste lädt bis zu 500 Einträge auf einmal. Für den erwarteten
|
||||
Bestand reicht das; eine Blätterfunktion kommt, wenn es eng wird.
|
||||
|
||||
### Commits
|
||||
|
||||
- 63c3c15 feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte
|
||||
- b3ca1c9 feat(aufgaben): Server Actions und Abfrageschicht
|
||||
- a4a889f feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte
|
||||
- 7c8fe10 test: Erfassungsweg und Eindeutigkeit der Quellen absichern
|
||||
- 144457b fix(aufgaben): Ideen aus Aufgabenliste und Inbox heraushalten
|
||||
- 4d0f83f chore: Demo-Daten für die neuen Seiten erweitern
|
||||
- d01c49b docs: Handbuchseiten zu Aufgaben, Ideen und Projekten
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
datum;start;ende;minuten;meilenstein;art;notiz
|
||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
||||
|
||||
|
@@ -133,14 +133,26 @@ E-Mail-Adresse und **Mit Passkey anmelden**.
|
||||
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
||||
> du dich aus, wenn ein Gerät verlorengeht.
|
||||
|
||||
## 8. Aufräumen
|
||||
## 8. Die erste Aufgabe erfassen
|
||||
|
||||
Auf der Startseite steht oben ein Feld **Was ist zu tun?**. Titel eintippen,
|
||||
Enter.
|
||||
|
||||
**Erwartet:** Eine kurze Bestätigung, und der Zähler neben **Inbox** im Menü
|
||||
springt auf 1. Dort kannst du die Aufgabe einem Bereich zuordnen.
|
||||
|
||||
Mehr dazu: [Aufgaben erfassen und wiederfinden](handbuch/aufgaben.md).
|
||||
|
||||
## 9. Aufräumen
|
||||
|
||||
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
||||
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
||||
|
||||
## Wie es weitergeht
|
||||
|
||||
- [Handbuch](handbuch/) – Anleitung für den Alltag
|
||||
- [Handbuch](handbuch/) – Anleitung für den Alltag, insbesondere
|
||||
[Aufgaben](handbuch/aufgaben.md), [Ideen](handbuch/ideen.md) und
|
||||
[Projekte](handbuch/projekte.md)
|
||||
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
||||
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 225 KiB |
@@ -9,9 +9,17 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
||||
Tageskapazität festlegen
|
||||
|
||||
## Im Alltag
|
||||
|
||||
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
||||
Inbox leeren, Filter, Status und Fälligkeit
|
||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
Fortschritt
|
||||
|
||||
## Verwaltung
|
||||
|
||||
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
6. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
|
||||
## Was noch kommt
|
||||
|
||||
@@ -19,8 +27,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||
|
||||
| Thema | Meilenstein |
|
||||
| ------------------------------- | ----------- |
|
||||
| Aufgaben erfassen, Inbox, Ideen | M2 |
|
||||
| Projekte und Arbeitspakete | M2 |
|
||||
| Kalenderkonten verbinden | M3 |
|
||||
| Wochenplanung und Zeitblöcke | M4 |
|
||||
| Benachrichtigungen | M5 |
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Aufgaben erfassen und wiederfinden
|
||||
|
||||
Der Planer trennt zwei Dinge, die sonst gern durcheinandergeraten:
|
||||
**etwas aufschreiben** und **entscheiden, wohin es gehört**. Das erste soll
|
||||
zwei Sekunden dauern, das zweite macht man gesammelt, wenn man Ruhe hat.
|
||||
|
||||
## Schnell erfassen
|
||||
|
||||
Auf der Startseite und in der Inbox steht ein einzelnes Feld. Titel eintippen,
|
||||
Enter — fertig.
|
||||
|
||||

|
||||
|
||||
Der Fokus bleibt danach im Feld, du kannst also mehrere Sachen hintereinander
|
||||
eingeben, ohne zur Maus zu greifen.
|
||||
|
||||
> **Warum nur ein Feld?** Wer beim Erfassen erst Bereich, Termin und Aufwand
|
||||
> ausfüllen muss, erfasst irgendwann nichts mehr, sondern merkt es sich
|
||||
> „schnell" — und vergisst es. Alles Weitere kommt beim Einsortieren.
|
||||
|
||||
## Die Inbox leeren
|
||||
|
||||
Alles ohne Bereich landet in der Inbox. Die Zahl neben dem Menüpunkt sagt, wie
|
||||
viel dort liegt.
|
||||
|
||||
Zum Einsortieren genügt das Auswahlfeld unter der Aufgabe: Bereich wählen, und
|
||||
sie verschwindet aus der Inbox.
|
||||
|
||||
> **Die Inbox ist kein eigener Zustand.** Eine Aufgabe ist genau dann in der
|
||||
> Inbox, wenn sie keinen Bereich hat. Einsortieren heißt schlicht: einen
|
||||
> Bereich setzen. Deshalb kann sie auch nicht „doppelt" irgendwo sein.
|
||||
|
||||
Wenn beim Einsortieren mehr zu ergänzen ist als der Bereich, klick auf das
|
||||
Stift-Symbol — dann öffnet sich das vollständige Formular direkt in der Liste.
|
||||
|
||||
## Die Aufgabenliste
|
||||
|
||||

|
||||
|
||||
Standardmäßig zeigt sie nur, was noch Arbeit bedeutet — Erledigtes und
|
||||
Verworfenes fällt heraus. Über den Filter _Status_ holst du es zurück.
|
||||
|
||||
### Die Reihenfolge
|
||||
|
||||
Sortiert wird nach Dringlichkeit, in dieser Reihenfolge:
|
||||
|
||||
1. Offenes vor Abgeschlossenem
|
||||
2. früherer Termin vor späterem
|
||||
3. Terminiertes vor Unterminiertem
|
||||
4. bei gleichem Termin: höhere Priorität zuerst
|
||||
5. zuletzt alphabetisch
|
||||
|
||||
Ohne Termin heißt also nicht unwichtig — solche Aufgaben stehen nur hinter den
|
||||
terminierten.
|
||||
|
||||
### Filter
|
||||
|
||||
| Filter | Was er tut |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| **Suche** | Durchsucht Titel und Notiz. Enter drücken |
|
||||
| **Bereich** | Ein Bereich, oder _Nur Inbox_ für alles ohne Bereich |
|
||||
| **Status** | Standard ist _Nur offene_. _Alle_ zeigt auch Erledigtes |
|
||||
| **Art** | Aufgabe, Arbeitspaket, Meilenstein, Idee oder Spiegel |
|
||||
| **Projekt** | Nur Aufgaben eines Projekts |
|
||||
|
||||
Die Filter stehen in der Adresszeile. Eine gefilterte Liste lässt sich also als
|
||||
Lesezeichen ablegen, und der Zurück-Knopf des Browsers tut, was er soll.
|
||||
|
||||
## Die Felder einer Aufgabe
|
||||
|
||||
| Feld | Bedeutung |
|
||||
| ------------- | ------------------------------------------------------------------------------------ |
|
||||
| **Titel** | Zwei bis 200 Zeichen. Am besten mit einem Verb: „Angebot schreiben", nicht „Angebot" |
|
||||
| **Notiz** | Was dazugehört, aber nicht in den Titel passt |
|
||||
| **Art** | Aufgabe, Idee, Arbeitspaket oder Meilenstein |
|
||||
| **Status** | Offen, Geplant, Wartet, Erledigt, Verworfen |
|
||||
| **Priorität** | 1 Sofort bis 5 Irgendwann. 3 ist der Normalfall |
|
||||
| **Aufwand** | Geschätzte Minuten, höchstens 480 |
|
||||
| **Fällig am** | Der Tag, an dem es spätestens fertig sein muss |
|
||||
| **Bereich** | Leer heißt Inbox |
|
||||
| **Projekt** | Optional |
|
||||
|
||||
> **Warum höchstens acht Stunden Aufwand?** Weil ab M4 aus einer Aufgabe ein
|
||||
> Zeitblock im Kalender wird. Was nicht an einem Tag am Stück zu schaffen ist,
|
||||
> gehört in mehrere Aufgaben — sonst lässt es sich nicht planen.
|
||||
|
||||
## Der Status
|
||||
|
||||
| Status | Wann |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| **Offen** | Steht an, ist noch nicht eingeplant |
|
||||
| **Geplant** | Hat ab M4 einen Zeitblock im Kalender |
|
||||
| **Wartet** | Liegt bei jemand anderem. Du kannst gerade nichts tun |
|
||||
| **Erledigt** | Fertig |
|
||||
| **Verworfen** | Wird nicht gemacht — aber die Notiz bleibt erhalten |
|
||||
|
||||
Das Häkchen links in jeder Zeile hakt direkt ab. Bei einer erledigten Aufgabe
|
||||
wird daraus ein Pfeil, der sie wieder öffnet.
|
||||
|
||||
> **Aus Erledigt und Verworfen geht es nur zurück nach Offen.** Nicht direkt
|
||||
> nach Geplant oder Wartet. Was wieder aufgenommen wird, fängt am Anfang an —
|
||||
> sonst entstehen Zustände, die niemand mehr nachvollziehen kann.
|
||||
|
||||
## Fälligkeit
|
||||
|
||||
Der Planer rechnet in **Kalendertagen in Europe/Berlin**, nicht in
|
||||
24-Stunden-Schritten. Was heute um 23:00 fällig ist, ist „heute fällig" — nicht
|
||||
„in 0,5 Tagen".
|
||||
|
||||
| Kennzeichen | Bedeutung |
|
||||
| ----------- | ------------------------ |
|
||||
| rot | überfällig |
|
||||
| gelb | heute oder morgen |
|
||||
| grau | später, oder ohne Termin |
|
||||
|
||||
Erledigtes wird nie als überfällig markiert, auch wenn der Termin lange
|
||||
zurückliegt.
|
||||
|
||||
## Löschen
|
||||
|
||||
Der Papierkorb fragt einmal nach: Beim ersten Klick wird der Knopf rot und
|
||||
fragt „Wirklich?", erst der zweite löscht. Klickst du daneben, ist die Frage
|
||||
wieder weg.
|
||||
|
||||
Meist ist **Verworfen** die bessere Wahl — dann bleibt nachvollziehbar, dass es
|
||||
die Aufgabe gab und warum sie nicht gemacht wurde.
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
| Meldung | Was zu tun ist |
|
||||
| --------------------------------------------------- | ------------------------------------------------------ |
|
||||
| „Mindestens zwei Zeichen." | Der Titel ist zu kurz |
|
||||
| „Mehr als acht Stunden gehört in mehrere Aufgaben." | Aufteilen |
|
||||
| „Dieser Statuswechsel ist nicht vorgesehen." | Erst wieder öffnen, dann weiter |
|
||||
| „Diesen Bereich gibt es nicht." | Der Bereich wurde inzwischen gelöscht. Seite neu laden |
|
||||
@@ -0,0 +1,48 @@
|
||||
# Ideen
|
||||
|
||||
Nicht alles, was einem einfällt, ist eine Aufgabe. Manches ist ein „das wäre
|
||||
mal schön" ohne Termin, ohne Aufwand und ohne Verpflichtung.
|
||||
|
||||
Solche Sachen gehören auf die Ideenseite — dort stören sie die Aufgabenliste
|
||||
nicht und gehen trotzdem nicht verloren.
|
||||
|
||||

|
||||
|
||||
## Eine Idee notieren
|
||||
|
||||
Titel eintippen, Enter. Genau wie bei Aufgaben, nur landet es auf der
|
||||
Ideenseite statt in der Inbox.
|
||||
|
||||
> Bewerten kannst du später. Der Sinn ist, sie festzuhalten, bevor sie weg ist.
|
||||
|
||||
## Aus einer Idee eine Aufgabe machen
|
||||
|
||||
Wenn aus der Idee etwas werden soll: **Zur Aufgabe machen**. Sie verschwindet
|
||||
damit von der Ideenseite und taucht in der Inbox auf — Bereich, Termin und
|
||||
Aufwand ergänzt du dann wie bei jeder anderen Aufgabe.
|
||||
|
||||
Der Titel und die Notiz bleiben erhalten, es entsteht kein zweiter Eintrag.
|
||||
|
||||
## Warum Ideen getrennt liegen
|
||||
|
||||
Ideen haben bewusst **keinen Termin**. Würden sie in der Aufgabenliste stehen,
|
||||
träfe eines von beidem zu:
|
||||
|
||||
- Sie stünden ganz unten und wären damit unsichtbar, oder
|
||||
- sie bekämen einen erfundenen Termin und würden irgendwann rot.
|
||||
|
||||
Beides führt dazu, dass man die Liste nicht mehr ernst nimmt. Getrennt bleibt
|
||||
die Aufgabenliste eine Liste von Verpflichtungen — und die Ideenseite eine
|
||||
Sammlung von Möglichkeiten.
|
||||
|
||||
## Eine Idee wieder loswerden
|
||||
|
||||
Der Papierkorb löscht endgültig. Wer sie lieber behalten möchte, ohne sie
|
||||
weiter zu sehen, setzt sie über das Stift-Symbol auf **Verworfen** — dann
|
||||
bleibt sie nachlesbar, verschwindet aber aus der Liste.
|
||||
|
||||
## Auf der Startseite
|
||||
|
||||
Sind Ideen da, zeigt die Startseite unten einen Hinweis mit ihrer Anzahl.
|
||||
Das ist Absicht: ein leiser Anstoß, gelegentlich hineinzusehen — aber nichts,
|
||||
was sich zwischen die Arbeit des Tages drängt.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Projekte
|
||||
|
||||
Ein **Projekt** ist eine Klammer um mehrere Aufgaben, die zusammen ein Ziel
|
||||
erreichen: „Website neu aufsetzen", „Werkstatt umräumen", „Zertifizierung".
|
||||
|
||||

|
||||
|
||||
Ein Projekt hat selbst **keinen Aufwand und keine Fälligkeit**. Beides ergibt
|
||||
sich aus dem, was drinsteckt — sonst hättest du zwei Zahlen, die auseinander
|
||||
laufen können.
|
||||
|
||||
## Ein Projekt anlegen
|
||||
|
||||
**Neues Projekt** oben rechts.
|
||||
|
||||
| Feld | Bedeutung |
|
||||
| ---------------- | ------------------------------------------------------------- |
|
||||
| **Name** | Zwei bis 80 Zeichen, eindeutig |
|
||||
| **Beschreibung** | Worum es geht — und woran du merkst, dass es fertig ist |
|
||||
| **Status** | Aktiv, Pausiert, Abgeschlossen, Verworfen |
|
||||
| **Bereich** | Optional. Neue Aufgaben im Projekt bekommen ihn vorgeschlagen |
|
||||
| **Zieltermin** | Rein informativ; er macht keine Aufgabe überfällig |
|
||||
|
||||
> Der zweite Satz der Beschreibung ist der wichtigere. „Fertig ist es, wenn die
|
||||
> neue Seite online ist und das Kontaktformular ankommt" verhindert, dass ein
|
||||
> Projekt ewig weiterläuft.
|
||||
|
||||
## Aufgaben zuordnen
|
||||
|
||||
Zwei Wege:
|
||||
|
||||
1. Auf der Projektseite **Aufgabe hinzufügen** — Projekt und Bereich sind dann
|
||||
schon vorbelegt, die Art steht auf _Arbeitspaket_.
|
||||
2. In einer bestehenden Aufgabe das Feld **Projekt** setzen.
|
||||
|
||||
### Arbeitspaket und Meilenstein
|
||||
|
||||
Beides sind Arten von Aufgaben _innerhalb_ eines Projekts:
|
||||
|
||||
- **Arbeitspaket** — ein Stück Arbeit mit Aufwand.
|
||||
- **Meilenstein** — ein Punkt, an dem etwas erreicht ist. Meist ohne Aufwand,
|
||||
dafür mit Termin.
|
||||
|
||||
## Der Fortschritt
|
||||
|
||||
Der Balken zeigt, wie viele der zugeordneten Aufgaben erledigt sind.
|
||||
|
||||
> **Verworfene Aufgaben zählen gar nicht mit.** Weder als erledigt noch als
|
||||
> offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde —
|
||||
> und das wäre eine Zahl, die lügt.
|
||||
|
||||
Ein Beispiel: drei Aufgaben, eine erledigt, eine offen, eine verworfen. Der
|
||||
Fortschritt ist **1 von 2 erledigt (50 %)**, nicht 33 %.
|
||||
|
||||
Daneben steht der noch offene geschätzte Aufwand. Aufgaben ohne Schätzung
|
||||
zählen darin als null — die Zahl ist also eine Untergrenze.
|
||||
|
||||
## Ein Projekt abschließen
|
||||
|
||||
Status auf **Abgeschlossen** setzen. Es verschwindet damit aus der Auswahl bei
|
||||
neuen Aufgaben, bleibt aber in der Übersicht sichtbar.
|
||||
|
||||
**Pausiert** ist für Projekte, die gerade ruhen, aber weitergehen sollen.
|
||||
|
||||
## Ein Projekt löschen
|
||||
|
||||
Der rote Knopf auf der Projektseite, mit Rückfrage.
|
||||
|
||||
> **Die enthaltenen Aufgaben bleiben erhalten** und verlieren nur ihre
|
||||
> Zuordnung. Mit dem Projekt auch die Arbeit zu löschen wäre selten das, was
|
||||
> jemand will — und nie rückgängig zu machen.
|
||||
|
||||
Willst du wirklich alles loswerden, lösche zuerst die Aufgaben über die
|
||||
Aufgabenliste (Filter _Projekt_), dann das Projekt.
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
| Meldung | Was zu tun ist |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| „Diesen Namen gibt es schon." | Projektnamen sind eindeutig. Vielleicht gibt es das Projekt schon abgeschlossen |
|
||||
| „Dieses Projekt gibt es nicht." | Es wurde inzwischen gelöscht. Seite neu laden |
|
||||
| Der Fortschritt springt beim Verwerfen nach oben | Das ist richtig so: Verworfenes fällt aus der Rechnung |
|
||||
@@ -50,7 +50,8 @@ test.describe('Anmeldung', () => {
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible()
|
||||
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat.
|
||||
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
||||
|
||||
await page.getByTitle('Abmelden').click()
|
||||
await expect(page).toHaveURL(/\/anmelden/)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
|
||||
/**
|
||||
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
|
||||
*
|
||||
* Alle Testdaten tragen ein gemeinsames Präfix und werden am Ende entfernt,
|
||||
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
|
||||
*/
|
||||
|
||||
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
||||
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||
const PRAEFIX = `E2E-${Date.now()}`
|
||||
|
||||
const verbindung = process.env.DATABASE_URL ?? ''
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||
|
||||
test.describe('Aufgaben', () => {
|
||||
test.skip(
|
||||
!EMAIL || !PASSWORT || !verbindung,
|
||||
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||
)
|
||||
|
||||
test.afterAll(async () => {
|
||||
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||
await db.projekt.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'aufgabe.' } } })
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
async function anmelden(seite: Page): Promise<void> {
|
||||
await seite.goto('/anmelden')
|
||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
}
|
||||
|
||||
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
|
||||
const titel = `${PRAEFIX} Angebot schreiben`
|
||||
await anmelden(page)
|
||||
|
||||
// --- Erfassen: ein Feld genügt --------------------------------------
|
||||
await page.goto('/inbox')
|
||||
await page.getByLabel('Neue Aufgabe').fill(titel)
|
||||
await page.getByRole('button', { name: 'Erfassen' }).click()
|
||||
|
||||
const zeile = page.locator('li').filter({ hasText: titel })
|
||||
await expect(zeile).toBeVisible()
|
||||
// Ohne Bereich liegt sie in der Inbox – genau das ist der Unterschied.
|
||||
await expect(zeile.getByText('Inbox')).toBeVisible()
|
||||
|
||||
// --- Einsortieren ----------------------------------------------------
|
||||
const bereich = await db.bereich.findFirst({
|
||||
where: { aktiv: true },
|
||||
orderBy: { reihenfolge: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
expect(bereich).not.toBeNull()
|
||||
|
||||
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
|
||||
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
|
||||
|
||||
// Aus der Inbox ist sie damit verschwunden.
|
||||
await page.goto('/inbox')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
|
||||
// --- Abhaken ---------------------------------------------------------
|
||||
await page.goto('/aufgaben')
|
||||
const inListe = page.locator('li').filter({ hasText: titel })
|
||||
await expect(inListe).toBeVisible()
|
||||
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
|
||||
|
||||
// Erledigtes fällt aus der Standardansicht heraus.
|
||||
await page.goto('/aufgaben')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
|
||||
// Mit Filter "Alle" ist sie wieder da.
|
||||
await page.goto('/aufgaben?status=ERLEDIGT')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toBeVisible()
|
||||
|
||||
const gespeichert = await db.aufgabe.findFirst({ where: { titel } })
|
||||
expect(gespeichert?.status).toBe('ERLEDIGT')
|
||||
expect(gespeichert?.erledigtAm).not.toBeNull()
|
||||
})
|
||||
|
||||
test('macht aus einer Idee eine Aufgabe', async ({ page }) => {
|
||||
const titel = `${PRAEFIX} Blog aufsetzen`
|
||||
await anmelden(page)
|
||||
|
||||
await page.goto('/ideen')
|
||||
await page.getByLabel('Neue Idee').fill(titel)
|
||||
await page.getByRole('button', { name: 'Erfassen' }).click()
|
||||
|
||||
const zeile = page.locator('li').filter({ hasText: titel })
|
||||
await expect(zeile).toBeVisible()
|
||||
|
||||
await zeile.getByRole('button', { name: 'Zur Aufgabe machen' }).click()
|
||||
await expect(page.getByText('ist jetzt eine Aufgabe')).toBeVisible()
|
||||
|
||||
const danach = await db.aufgabe.findFirst({ where: { titel } })
|
||||
expect(danach?.typ).toBe('AUFGABE')
|
||||
|
||||
// Aus der Ideenliste ist sie damit verschwunden.
|
||||
await page.goto('/ideen')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('hält Ideen aus der Aufgabenliste heraus', async ({ page }) => {
|
||||
const idee = `${PRAEFIX} Nur eine Idee`
|
||||
const aufgabe = `${PRAEFIX} Eine echte Aufgabe`
|
||||
await db.aufgabe.createMany({
|
||||
data: [
|
||||
{ titel: idee, typ: 'IDEE' },
|
||||
{ titel: aufgabe, typ: 'AUFGABE' },
|
||||
],
|
||||
})
|
||||
|
||||
await anmelden(page)
|
||||
|
||||
// In der Aufgabenliste hat die Idee nichts zu suchen.
|
||||
await page.goto('/aufgaben')
|
||||
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
|
||||
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
|
||||
|
||||
// Auch nicht in der Inbox, obwohl beide keinen Bereich haben.
|
||||
await page.goto('/inbox')
|
||||
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
|
||||
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
|
||||
|
||||
// Wer ausdrücklich danach filtert, sieht sie sehr wohl.
|
||||
await page.goto('/aufgaben?typ=IDEE')
|
||||
await expect(page.locator('li').filter({ hasText: idee })).toBeVisible()
|
||||
await expect(page.locator('li').filter({ hasText: aufgabe })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filtert nach Bereich und über die Adresszeile', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
const bereich = await db.bereich.findFirst({
|
||||
where: { aktiv: true },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const drin = `${PRAEFIX} Gehört dazu`
|
||||
const draussen = `${PRAEFIX} Gehört nicht dazu`
|
||||
await db.aufgabe.createMany({
|
||||
data: [
|
||||
{ titel: drin, bereichId: bereich!.id },
|
||||
{ titel: draussen, bereichId: null },
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/aufgaben?bereich=${bereich!.id}`)
|
||||
await expect(page.locator('li').filter({ hasText: drin })).toBeVisible()
|
||||
await expect(page.locator('li').filter({ hasText: draussen })).toHaveCount(0)
|
||||
|
||||
// Nur Inbox zeigt genau die andere.
|
||||
await page.goto('/aufgaben?bereich=inbox')
|
||||
await expect(page.locator('li').filter({ hasText: draussen })).toBeVisible()
|
||||
await expect(page.locator('li').filter({ hasText: drin })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('zeigt den Fortschritt eines Projekts', async ({ page }) => {
|
||||
const projektName = `${PRAEFIX} Website neu`
|
||||
const projekt = await db.projekt.create({ data: { name: projektName } })
|
||||
await db.aufgabe.createMany({
|
||||
data: [
|
||||
{ titel: `${PRAEFIX} Entwurf`, projektId: projekt.id, status: 'ERLEDIGT' },
|
||||
{ titel: `${PRAEFIX} Umsetzung`, projektId: projekt.id, status: 'OFFEN' },
|
||||
// Verworfenes zählt bewusst gar nicht mit.
|
||||
{ titel: `${PRAEFIX} Verworfen`, projektId: projekt.id, status: 'VERWORFEN' },
|
||||
],
|
||||
})
|
||||
|
||||
await anmelden(page)
|
||||
await page.goto(`/projekte/${projekt.id}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: projektName })).toBeVisible()
|
||||
await expect(page.getByText('1 von 2 erledigt (50 %)')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
|
||||
@@ -105,6 +105,118 @@ const PROTOKOLL = [
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen
|
||||
* Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig,
|
||||
* heute und später zeigen.
|
||||
*/
|
||||
const AUFGABEN = [
|
||||
// Überfällig
|
||||
{
|
||||
titel: 'Angebot Heizungstausch nachfassen',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -3,
|
||||
prioritaet: 2,
|
||||
aufwand: 30,
|
||||
},
|
||||
{
|
||||
titel: 'Rechnung 2026-114 korrigieren',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -1,
|
||||
prioritaet: 1,
|
||||
aufwand: 20,
|
||||
},
|
||||
// Heute und morgen
|
||||
{
|
||||
titel: 'Wochenbericht an die Geschäftsführung',
|
||||
bereich: 'Firma',
|
||||
versatz: 0,
|
||||
prioritaet: 2,
|
||||
aufwand: 45,
|
||||
},
|
||||
{
|
||||
titel: 'Rückruf Frau Sonnenberg',
|
||||
bereich: 'Firma',
|
||||
versatz: 0,
|
||||
prioritaet: 1,
|
||||
aufwand: 15,
|
||||
},
|
||||
{
|
||||
titel: 'Zahnarzttermin bestätigen',
|
||||
bereich: 'Privat',
|
||||
versatz: 1,
|
||||
prioritaet: 3,
|
||||
aufwand: 10,
|
||||
},
|
||||
// Später
|
||||
{
|
||||
titel: 'Backup-Strategie für den Server überdenken',
|
||||
bereich: 'Homelab',
|
||||
versatz: 9,
|
||||
prioritaet: 4,
|
||||
aufwand: 120,
|
||||
},
|
||||
{
|
||||
titel: 'Reifen wechseln lassen',
|
||||
bereich: 'Privat',
|
||||
versatz: 14,
|
||||
prioritaet: 3,
|
||||
aufwand: 60,
|
||||
},
|
||||
// Ohne Termin
|
||||
{
|
||||
titel: 'Ablage im Keller sortieren',
|
||||
bereich: 'Privat',
|
||||
versatz: null,
|
||||
prioritaet: 5,
|
||||
aufwand: 180,
|
||||
},
|
||||
// Erledigt
|
||||
{
|
||||
titel: 'Steuerunterlagen zusammenstellen',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -5,
|
||||
prioritaet: 3,
|
||||
aufwand: 90,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
]
|
||||
|
||||
/** Liegt noch in der Inbox – ohne Bereich. */
|
||||
const INBOX = [
|
||||
{ titel: 'Sicherungskasten beschriften' },
|
||||
{ titel: 'Angebot Netzwerkschrank einholen' },
|
||||
]
|
||||
|
||||
const IDEEN = [
|
||||
{ titel: 'Newsletter für Bestandskunden' },
|
||||
{ titel: 'Werkstatt-Podcast aufnehmen' },
|
||||
{ titel: 'Regale im Lager neu beschriften' },
|
||||
]
|
||||
|
||||
const PROJEKT_AUFGABEN = [
|
||||
{
|
||||
titel: 'Anforderungen sammeln',
|
||||
typ: 'ARBEITSPAKET' as const,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
{
|
||||
titel: 'Entwurf abstimmen',
|
||||
typ: 'ARBEITSPAKET' as const,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
{ titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const },
|
||||
{ titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const },
|
||||
{ titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const },
|
||||
]
|
||||
|
||||
/** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */
|
||||
const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z')
|
||||
|
||||
function tageVersetzt(tage: number): Date {
|
||||
return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Demo-Daten einspielen.')
|
||||
|
||||
@@ -158,6 +270,77 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// --- Aufgaben, Ideen und ein Projekt -------------------------------------
|
||||
await db.aufgabe.deleteMany({})
|
||||
await db.projekt.deleteMany({})
|
||||
|
||||
const bereichsKennung = new Map(
|
||||
(await db.bereich.findMany({ select: { id: true, name: true } })).map((b) => [
|
||||
b.name,
|
||||
b.id,
|
||||
]),
|
||||
)
|
||||
|
||||
for (const eintrag of AUFGABEN) {
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
titel: eintrag.titel,
|
||||
bereichId: bereichsKennung.get(eintrag.bereich) ?? null,
|
||||
prioritaet: eintrag.prioritaet,
|
||||
aufwandMinuten: eintrag.aufwand,
|
||||
faelligAm: eintrag.versatz === null ? null : tageVersetzt(eintrag.versatz),
|
||||
status: eintrag.status ?? 'OFFEN',
|
||||
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-4) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const eintrag of INBOX) {
|
||||
await db.aufgabe.create({ data: { titel: eintrag.titel } })
|
||||
}
|
||||
|
||||
for (const eintrag of IDEEN) {
|
||||
await db.aufgabe.create({ data: { titel: eintrag.titel, typ: 'IDEE' } })
|
||||
}
|
||||
|
||||
const projekt = await db.projekt.create({
|
||||
data: {
|
||||
name: 'Website neu aufsetzen',
|
||||
beschreibung:
|
||||
'Der alte Auftritt ist von 2019 und auf dem Handy kaum lesbar. Fertig ist es, wenn die neue Seite online ist und das Kontaktformular ankommt.',
|
||||
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||||
zielAm: tageVersetzt(45),
|
||||
},
|
||||
})
|
||||
|
||||
for (const eintrag of PROJEKT_AUFGABEN) {
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
titel: eintrag.titel,
|
||||
typ: eintrag.typ,
|
||||
status: eintrag.status,
|
||||
projektId: projekt.id,
|
||||
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||||
aufwandMinuten: eintrag.typ === 'MEILENSTEIN' ? null : 120,
|
||||
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-7) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
titel: 'Störung Klimagerät Serverraum prüfen',
|
||||
bereichId: bereichsKennung.get('Firma') ?? null,
|
||||
prioritaet: 2,
|
||||
aufwandMinuten: 60,
|
||||
faelligAm: tageVersetzt(2),
|
||||
quelle: 'ZAMMAD',
|
||||
quelleRef: 'demo-38421',
|
||||
quelleUrl: 'https://zammad.example.invalid/ticket/38421',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projekt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"beschreibung" TEXT,
|
||||
"status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV',
|
||||
"bereichId" TEXT,
|
||||
"zielAm" TIMESTAMP(3),
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projekt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "aufgabe" (
|
||||
"id" TEXT NOT NULL,
|
||||
"titel" TEXT NOT NULL,
|
||||
"notiz" TEXT,
|
||||
"typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE',
|
||||
"status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"prioritaet" INTEGER NOT NULL DEFAULT 3,
|
||||
"aufwandMinuten" INTEGER,
|
||||
"faelligAm" TIMESTAMP(3),
|
||||
"erledigtAm" TIMESTAMP(3),
|
||||
"bereichId" TEXT,
|
||||
"projektId" TEXT,
|
||||
"quelle" "Quelle" NOT NULL DEFAULT 'MANUELL',
|
||||
"quelleUrl" TEXT,
|
||||
"quelleRef" TEXT,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projekt_status_idx" ON "projekt"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -121,10 +121,116 @@ model Bereich {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
aufgaben Aufgabe[]
|
||||
projekte Projekt[]
|
||||
|
||||
@@index([aktiv, reihenfolge])
|
||||
@@map("bereich")
|
||||
}
|
||||
|
||||
enum AufgabenTyp {
|
||||
AUFGABE
|
||||
IDEE
|
||||
ARBEITSPAKET
|
||||
MEILENSTEIN
|
||||
SPIEGEL
|
||||
}
|
||||
|
||||
enum AufgabenStatus {
|
||||
OFFEN
|
||||
GEPLANT
|
||||
WARTET
|
||||
ERLEDIGT
|
||||
VERWORFEN
|
||||
}
|
||||
|
||||
/// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef].
|
||||
enum Quelle {
|
||||
MANUELL
|
||||
ZAMMAD
|
||||
WARTUNGSPLANER
|
||||
TELEGRAM
|
||||
SPIEGEL
|
||||
MAIL
|
||||
API
|
||||
MCP
|
||||
}
|
||||
|
||||
enum ProjektStatus {
|
||||
AKTIV
|
||||
PAUSIERT
|
||||
ABGESCHLOSSEN
|
||||
VERWORFEN
|
||||
}
|
||||
|
||||
/// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine
|
||||
/// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben.
|
||||
model Projekt {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
beschreibung String?
|
||||
status ProjektStatus @default(AKTIV)
|
||||
bereichId String?
|
||||
/// Wunschtermin fuer den Abschluss, rein informativ.
|
||||
zielAm DateTime?
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||
aufgaben Aufgabe[]
|
||||
|
||||
@@unique([name])
|
||||
@@index([status])
|
||||
@@index([bereichId])
|
||||
@@map("projekt")
|
||||
}
|
||||
|
||||
/// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein.
|
||||
///
|
||||
/// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied,
|
||||
/// "einsortieren" heisst schlicht: einen Bereich setzen.
|
||||
model Aufgabe {
|
||||
id String @id @default(cuid())
|
||||
titel String
|
||||
notiz String?
|
||||
typ AufgabenTyp @default(AUFGABE)
|
||||
status AufgabenStatus @default(OFFEN)
|
||||
|
||||
/// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall.
|
||||
prioritaet Int @default(3)
|
||||
/// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt".
|
||||
aufwandMinuten Int?
|
||||
|
||||
faelligAm DateTime?
|
||||
erledigtAm DateTime?
|
||||
|
||||
bereichId String?
|
||||
projektId String?
|
||||
|
||||
/// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis.
|
||||
quelle Quelle @default(MANUELL)
|
||||
quelleUrl String?
|
||||
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
|
||||
quelleRef String?
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
|
||||
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
|
||||
/// als verschieden an, sie behindern sich also nicht.
|
||||
@@unique([quelle, quelleRef])
|
||||
@@index([status, faelligAm])
|
||||
@@index([bereichId, status])
|
||||
@@index([projektId])
|
||||
@@index([typ, status])
|
||||
@@map("aufgabe")
|
||||
}
|
||||
|
||||
/// Unveraenderlicher Nachweis jeder schreibenden Aktion.
|
||||
model AuditEintrag {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -29,6 +29,10 @@ type Aufnahme = {
|
||||
const AUFNAHMEN: Aufnahme[] = [
|
||||
{ name: 'anmelden', pfad: '/anmelden' },
|
||||
{ name: 'start', pfad: '/' },
|
||||
{ name: 'inbox', pfad: '/inbox' },
|
||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||
{ name: 'projekte', pfad: '/projekte' },
|
||||
{ name: 'ideen', pfad: '/ideen' },
|
||||
{ name: 'bereiche', pfad: '/bereiche' },
|
||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||
|
||||
@@ -44,8 +44,11 @@ const kennung = `zzz-test-${Date.now()}`
|
||||
const testEmail = `${kennung}@example.invalid`
|
||||
|
||||
async function aufraeumen(): Promise<void> {
|
||||
// Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag
|
||||
// hängt bewusst an keinem Benutzer mehr.
|
||||
// Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise
|
||||
// stehen, die den Bereich am Löschen hindern.
|
||||
await db.aufgabe.deleteMany({ where: { titel: { startsWith: kennung } } })
|
||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
||||
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
@@ -174,6 +177,120 @@ async function main(): Promise<void> {
|
||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
||||
)
|
||||
|
||||
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: { titel: `${kennung} Angebot schreiben` },
|
||||
})
|
||||
pruefe('Aufgabe anlegen', aufgabe.id.length > 0)
|
||||
pruefe(
|
||||
'Ohne Angabe ist eine Aufgabe offen, normal und von Hand erfasst',
|
||||
aufgabe.status === 'OFFEN' &&
|
||||
aufgabe.typ === 'AUFGABE' &&
|
||||
aufgabe.prioritaet === 3 &&
|
||||
aufgabe.quelle === 'MANUELL',
|
||||
)
|
||||
pruefe('Ohne Bereich liegt sie in der Inbox', aufgabe.bereichId === null)
|
||||
|
||||
// --- Eindeutigkeit der Quelle -------------------------------------------
|
||||
// Von Hand erfasste Aufgaben haben quelleRef = null. PostgreSQL sieht
|
||||
// NULL-Werte als verschieden an, deshalb behindern sie sich nicht.
|
||||
const zweiteOhneRef = await db.aufgabe.create({
|
||||
data: { titel: `${kennung} Noch eine von Hand` },
|
||||
})
|
||||
pruefe(
|
||||
'Mehrere Aufgaben ohne Quellverweis sind erlaubt',
|
||||
zweiteOhneRef.id !== aufgabe.id,
|
||||
)
|
||||
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
titel: `${kennung} Ticket 4711`,
|
||||
quelle: 'ZAMMAD',
|
||||
quelleRef: `${kennung}-4711`,
|
||||
quelleUrl: 'https://zammad.example.invalid/4711',
|
||||
},
|
||||
})
|
||||
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal', () =>
|
||||
db.aufgabe.create({
|
||||
data: {
|
||||
titel: `${kennung} Ticket 4711 doppelt`,
|
||||
quelle: 'ZAMMAD',
|
||||
quelleRef: `${kennung}-4711`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Dieselbe Kennung bei anderer Quelle ist dagegen in Ordnung.
|
||||
const andereQuelle = await db.aufgabe.create({
|
||||
data: {
|
||||
titel: `${kennung} Telegram 4711`,
|
||||
quelle: 'TELEGRAM',
|
||||
quelleRef: `${kennung}-4711`,
|
||||
},
|
||||
})
|
||||
pruefe('Dieselbe Kennung bei anderer Quelle ist erlaubt', andereQuelle.id.length > 0)
|
||||
|
||||
// So werden Integrationen es tun: upsert statt anlegen.
|
||||
const gewandelt = await db.aufgabe.upsert({
|
||||
where: {
|
||||
quelle_quelleRef: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` },
|
||||
},
|
||||
update: { titel: `${kennung} Ticket 4711 aktualisiert` },
|
||||
create: {
|
||||
titel: `${kennung} Ticket 4711 neu`,
|
||||
quelle: 'ZAMMAD',
|
||||
quelleRef: `${kennung}-4711`,
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Ein Upsert über [quelle, quelleRef] aktualisiert statt zu doppeln',
|
||||
gewandelt.titel.endsWith('aktualisiert') &&
|
||||
(await db.aufgabe.count({
|
||||
where: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` },
|
||||
})) === 1,
|
||||
)
|
||||
|
||||
// --- Projekt: Zuordnung überlebt das Löschen nicht, die Aufgabe schon ----
|
||||
const projekt = await db.projekt.create({ data: { name: `${kennung} Website` } })
|
||||
await db.aufgabe.update({
|
||||
where: { id: aufgabe.id },
|
||||
data: { projektId: projekt.id },
|
||||
})
|
||||
|
||||
await scheitert('Projektname ist eindeutig', () =>
|
||||
db.projekt.create({ data: { name: projekt.name } }),
|
||||
)
|
||||
|
||||
await db.projekt.delete({ where: { id: projekt.id } })
|
||||
const nachProjektLoeschung = await db.aufgabe.findUnique({
|
||||
where: { id: aufgabe.id },
|
||||
select: { id: true, projektId: true },
|
||||
})
|
||||
pruefe('Die Aufgabe überlebt das Löschen ihres Projekts', nachProjektLoeschung !== null)
|
||||
pruefe(
|
||||
'Nur die Zuordnung zum Projekt wird gelöst',
|
||||
nachProjektLoeschung?.projektId === null,
|
||||
)
|
||||
|
||||
// --- Bereich: dasselbe Verhalten ----------------------------------------
|
||||
const bereich2 = await db.bereich.create({
|
||||
data: { name: `${kennung}-zweiter`, farbe: '#16a34a' },
|
||||
})
|
||||
await db.aufgabe.update({
|
||||
where: { id: aufgabe.id },
|
||||
data: { bereichId: bereich2.id },
|
||||
})
|
||||
await db.bereich.delete({ where: { id: bereich2.id } })
|
||||
|
||||
const nachBereichLoeschung = await db.aufgabe.findUnique({
|
||||
where: { id: aufgabe.id },
|
||||
select: { bereichId: true },
|
||||
})
|
||||
pruefe(
|
||||
'Beim Löschen eines Bereichs fällt die Aufgabe zurück in die Inbox',
|
||||
nachBereichLoeschung?.bereichId === null,
|
||||
)
|
||||
}
|
||||
|
||||
let abbruch: unknown = null
|
||||
|
||||
@@ -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,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<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') } : {}),
|
||||
// Ideen haben ihre eigene Seite; wer sie hier sehen will, filtert danach.
|
||||
ohneIdeen: true,
|
||||
}
|
||||
}
|
||||
|
||||
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, ohneIdeen: 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>
|
||||
|
||||
@@ -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({
|
||||
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() {
|
||||
<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,7 +106,9 @@ export default async function StartSeite() {
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereiche.map((bereich) => (
|
||||
{bereichsListe.map((bereich) => {
|
||||
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||
return (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
@@ -57,25 +121,34 @@ export default async function StartSeite() {
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||
{offeneHier.length === 0
|
||||
? 'Nichts offen'
|
||||
: `${offeneHier.length} offen`}{' '}
|
||||
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{zahlen.ideen > 0 && (
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||
<CardTitel className="text-base">
|
||||
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
|
||||
{zahlen.ideen} Idee(n) warten
|
||||
</Link>
|
||||
</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||
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 }
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { faelligkeitFarbe, fuerAnzeige, type AufgabeRoh } from './aufgabe-anzeige'
|
||||
|
||||
// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit.
|
||||
const jetzt = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
|
||||
return {
|
||||
id: 'a1',
|
||||
titel: 'Angebot schreiben',
|
||||
notiz: null,
|
||||
typ: 'AUFGABE',
|
||||
status: 'OFFEN',
|
||||
prioritaet: 3,
|
||||
aufwandMinuten: 60,
|
||||
faelligAm: null,
|
||||
quelle: 'MANUELL',
|
||||
quelleUrl: null,
|
||||
bereich: null,
|
||||
projekt: null,
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fuerAnzeige', () => {
|
||||
it('lässt den Datumswert leer, wenn kein Termin gesetzt ist', () => {
|
||||
const anzeige = fuerAnzeige(roh(), jetzt)
|
||||
expect(anzeige.faelligWert).toBe('')
|
||||
expect(anzeige.faelligDatum).toBeNull()
|
||||
expect(anzeige.faelligText).toBe('ohne Termin')
|
||||
expect(anzeige.ueberfaellig).toBe(false)
|
||||
})
|
||||
|
||||
it('gibt den Datumswert im Format des Eingabefelds zurück', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.faelligWert).toBe('2026-03-20')
|
||||
})
|
||||
|
||||
it('nimmt den Berliner Kalendertag, nicht den UTC-Tag', () => {
|
||||
// 23:30 UTC am 19. ist in Berlin bereits der 20. um 00:30.
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-19T23:30:00Z') }),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.faelligWert).toBe('2026-03-20')
|
||||
})
|
||||
|
||||
it('markiert eine offene Aufgabe von gestern als überfällig', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-16T09:00:00Z') }),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.ueberfaellig).toBe(true)
|
||||
expect(anzeige.faelligText).toBe('seit gestern überfällig')
|
||||
})
|
||||
|
||||
it('markiert eine erledigte Aufgabe von gestern nicht als überfällig', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-16T09:00:00Z'), status: 'ERLEDIGT' }),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.ueberfaellig).toBe(false)
|
||||
})
|
||||
|
||||
it('schreibt das Datum ausgeschrieben für den Hinweis', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.faelligDatum).toBe('Fr., 20.03.2026')
|
||||
})
|
||||
|
||||
it('reicht Bereich und Projekt unverändert durch', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({
|
||||
bereich: { id: 'b1', name: 'Gewerbe', farbe: '#7c3aed' },
|
||||
projekt: { id: 'p1', name: 'Website neu' },
|
||||
}),
|
||||
jetzt,
|
||||
)
|
||||
expect(anzeige.bereich?.name).toBe('Gewerbe')
|
||||
expect(anzeige.projekt?.name).toBe('Website neu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('faelligkeitFarbe', () => {
|
||||
it('warnt rot bei Überfälligkeit', () => {
|
||||
const anzeige = fuerAnzeige(
|
||||
roh({ faelligAm: new Date('2026-03-15T09:00:00Z') }),
|
||||
jetzt,
|
||||
)
|
||||
expect(faelligkeitFarbe(anzeige)).toBe('gefahr')
|
||||
})
|
||||
|
||||
it('warnt gelb für heute und morgen', () => {
|
||||
expect(
|
||||
faelligkeitFarbe(
|
||||
fuerAnzeige(roh({ faelligAm: new Date('2026-03-17T15:00:00Z') }), jetzt),
|
||||
),
|
||||
).toBe('warnung')
|
||||
expect(
|
||||
faelligkeitFarbe(
|
||||
fuerAnzeige(roh({ faelligAm: new Date('2026-03-18T15:00:00Z') }), jetzt),
|
||||
),
|
||||
).toBe('warnung')
|
||||
})
|
||||
|
||||
it('bleibt bei allem Weiteren unauffällig', () => {
|
||||
expect(
|
||||
faelligkeitFarbe(
|
||||
fuerAnzeige(roh({ faelligAm: new Date('2026-04-01T09:00:00Z') }), jetzt),
|
||||
),
|
||||
).toBe('neutral')
|
||||
expect(faelligkeitFarbe(fuerAnzeige(roh(), jetzt))).toBe('neutral')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AufgabenStatus, AufgabenTyp, Quelle } from '@/generated/prisma/enums'
|
||||
import {
|
||||
faelligkeitEinordnen,
|
||||
faelligkeitText,
|
||||
istUeberfaellig,
|
||||
type Faelligkeit,
|
||||
} from '@/lib/aufgabe'
|
||||
import { alsDatum, alsTagesschluessel } from '@/lib/zeit'
|
||||
|
||||
/**
|
||||
* Bereitet eine Aufgabe für die Anzeige auf.
|
||||
*
|
||||
* Reine Funktion mit dem Zeitpunkt als Parameter: Formatierung und
|
||||
* Fälligkeitsrechnung passieren serverseitig in Europe/Berlin, damit im
|
||||
* Browser nichts nachgerechnet wird und alle Listen gleich aussehen.
|
||||
*/
|
||||
|
||||
export type AufgabeRoh = {
|
||||
id: string
|
||||
titel: string
|
||||
notiz: string | null
|
||||
typ: AufgabenTyp
|
||||
status: AufgabenStatus
|
||||
prioritaet: number
|
||||
aufwandMinuten: number | null
|
||||
faelligAm: Date | null
|
||||
quelle: Quelle
|
||||
quelleUrl: string | null
|
||||
bereich: { id: string; name: string; farbe: string } | null
|
||||
projekt: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export type AufgabeAnzeige = {
|
||||
id: string
|
||||
titel: string
|
||||
notiz: string | null
|
||||
typ: AufgabenTyp
|
||||
status: AufgabenStatus
|
||||
prioritaet: number
|
||||
aufwandMinuten: number | null
|
||||
/** ISO-Datum für das Formularfeld, sonst leer. */
|
||||
faelligWert: string
|
||||
/** "heute fällig", "seit 3 Tagen überfällig", "ohne Termin". */
|
||||
faelligText: string
|
||||
/** Ausgeschriebenes Datum für den Titelhinweis, sonst null. */
|
||||
faelligDatum: string | null
|
||||
faelligkeit: Faelligkeit['art']
|
||||
ueberfaellig: boolean
|
||||
quelle: Quelle
|
||||
quelleUrl: string | null
|
||||
bereich: { id: string; name: string; farbe: string } | null
|
||||
projekt: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-Datum in Europe/Berlin, passend für ein <input type="date">.
|
||||
* Der Wert des Feldes ist ein reines Datum ohne Zeitzone, also der Berliner
|
||||
* Kalendertag — dieselbe Rechnung wie überall sonst im Planer.
|
||||
*/
|
||||
function alsDatumswert(zeitpunkt: Date | null): string {
|
||||
return zeitpunkt ? alsTagesschluessel(zeitpunkt) : ''
|
||||
}
|
||||
|
||||
export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
|
||||
const faelligkeit = faelligkeitEinordnen(aufgabe.faelligAm, jetzt)
|
||||
|
||||
return {
|
||||
id: aufgabe.id,
|
||||
titel: aufgabe.titel,
|
||||
notiz: aufgabe.notiz,
|
||||
typ: aufgabe.typ,
|
||||
status: aufgabe.status,
|
||||
prioritaet: aufgabe.prioritaet,
|
||||
aufwandMinuten: aufgabe.aufwandMinuten,
|
||||
faelligWert: alsDatumswert(aufgabe.faelligAm),
|
||||
faelligText: faelligkeitText(faelligkeit),
|
||||
faelligDatum: aufgabe.faelligAm ? alsDatum(aufgabe.faelligAm) : null,
|
||||
faelligkeit: faelligkeit.art,
|
||||
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
|
||||
quelle: aufgabe.quelle,
|
||||
quelleUrl: aufgabe.quelleUrl,
|
||||
bereich: aufgabe.bereich,
|
||||
projekt: aufgabe.projekt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie dringend die Fälligkeit aussieht — steuert die Farbe des Kennzeichens. */
|
||||
export function faelligkeitFarbe(
|
||||
anzeige: AufgabeAnzeige,
|
||||
): 'neutral' | 'warnung' | 'gefahr' {
|
||||
if (anzeige.ueberfaellig) return 'gefahr'
|
||||
if (anzeige.faelligkeit === 'heute' || anzeige.faelligkeit === 'morgen') {
|
||||
return 'warnung'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
aufgabeSchema,
|
||||
aufwandSumme,
|
||||
faelligkeitEinordnen,
|
||||
faelligkeitText,
|
||||
fortschrittBerechnen,
|
||||
istAbgeschlossen,
|
||||
istOffen,
|
||||
istUeberfaellig,
|
||||
moeglicheUebergaenge,
|
||||
nachDringlichkeit,
|
||||
projektSchema,
|
||||
schnellerfassungSchema,
|
||||
uebergangErlaubt,
|
||||
type SortierbareAufgabe,
|
||||
} from './aufgabe'
|
||||
|
||||
// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit.
|
||||
const jetzt = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
describe('istOffen und istAbgeschlossen', () => {
|
||||
it('trennt die beiden Gruppen vollständig', () => {
|
||||
for (const status of ['OFFEN', 'GEPLANT', 'WARTET'] as const) {
|
||||
expect(istOffen(status)).toBe(true)
|
||||
expect(istAbgeschlossen(status)).toBe(false)
|
||||
}
|
||||
for (const status of ['ERLEDIGT', 'VERWORFEN'] as const) {
|
||||
expect(istOffen(status)).toBe(false)
|
||||
expect(istAbgeschlossen(status)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('uebergangErlaubt', () => {
|
||||
it('lässt den Wechsel auf sich selbst zu', () => {
|
||||
expect(uebergangErlaubt('OFFEN', 'OFFEN')).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt aus offenen Zuständen alles zu', () => {
|
||||
expect(uebergangErlaubt('OFFEN', 'ERLEDIGT')).toBe(true)
|
||||
expect(uebergangErlaubt('GEPLANT', 'WARTET')).toBe(true)
|
||||
expect(uebergangErlaubt('WARTET', 'VERWORFEN')).toBe(true)
|
||||
})
|
||||
|
||||
it('führt aus abgeschlossenen Zuständen nur zurück nach OFFEN', () => {
|
||||
expect(uebergangErlaubt('ERLEDIGT', 'OFFEN')).toBe(true)
|
||||
expect(uebergangErlaubt('ERLEDIGT', 'GEPLANT')).toBe(false)
|
||||
expect(uebergangErlaubt('VERWORFEN', 'OFFEN')).toBe(true)
|
||||
expect(uebergangErlaubt('VERWORFEN', 'ERLEDIGT')).toBe(false)
|
||||
})
|
||||
|
||||
it('nennt für einen abgeschlossenen Zustand genau einen Ausweg', () => {
|
||||
expect(moeglicheUebergaenge('ERLEDIGT')).toEqual(['OFFEN'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('faelligkeitEinordnen', () => {
|
||||
it('erkennt fehlenden Termin', () => {
|
||||
expect(faelligkeitEinordnen(null, jetzt)).toEqual({ art: 'ohne' })
|
||||
expect(faelligkeitEinordnen(undefined, jetzt)).toEqual({ art: 'ohne' })
|
||||
})
|
||||
|
||||
it('erkennt heute, auch spät am Abend', () => {
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-17T22:00:00Z'), jetzt)).toEqual({
|
||||
art: 'heute',
|
||||
})
|
||||
})
|
||||
|
||||
it('rechnet in Kalendertagen, nicht in 24-Stunden-Schritten', () => {
|
||||
// 23:30 UTC am 17. ist in Berlin bereits der 18. um 00:30.
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-17T23:30:00Z'), jetzt)).toEqual({
|
||||
art: 'morgen',
|
||||
})
|
||||
})
|
||||
|
||||
it('erkennt morgen', () => {
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-18T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'morgen',
|
||||
})
|
||||
})
|
||||
|
||||
it('fasst die nächste Woche zusammen', () => {
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-22T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'diese-woche',
|
||||
tage: 5,
|
||||
})
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-24T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'diese-woche',
|
||||
tage: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('schiebt alles darüber nach später', () => {
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-25T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'spaeter',
|
||||
tage: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('zählt Überfälligkeit in Tagen', () => {
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-16T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'ueberfaellig',
|
||||
tage: 1,
|
||||
})
|
||||
expect(faelligkeitEinordnen(new Date('2026-03-10T09:00:00Z'), jetzt)).toEqual({
|
||||
art: 'ueberfaellig',
|
||||
tage: 7,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('faelligkeitText', () => {
|
||||
it('schreibt jeden Fall auf Deutsch', () => {
|
||||
expect(faelligkeitText({ art: 'ohne' })).toBe('ohne Termin')
|
||||
expect(faelligkeitText({ art: 'heute' })).toBe('heute fällig')
|
||||
expect(faelligkeitText({ art: 'morgen' })).toBe('morgen fällig')
|
||||
expect(faelligkeitText({ art: 'diese-woche', tage: 3 })).toBe('in 3 Tagen fällig')
|
||||
expect(faelligkeitText({ art: 'spaeter', tage: 20 })).toBe('in 20 Tagen fällig')
|
||||
})
|
||||
|
||||
it('sagt bei einem Tag Rückstand "seit gestern"', () => {
|
||||
expect(faelligkeitText({ art: 'ueberfaellig', tage: 1 })).toBe(
|
||||
'seit gestern überfällig',
|
||||
)
|
||||
expect(faelligkeitText({ art: 'ueberfaellig', tage: 4 })).toBe(
|
||||
'seit 4 Tagen überfällig',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('istUeberfaellig', () => {
|
||||
it('erkennt eine offene Aufgabe von gestern', () => {
|
||||
expect(istUeberfaellig(new Date('2026-03-16T09:00:00Z'), 'OFFEN', jetzt)).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt den heutigen Tag nicht als überfällig', () => {
|
||||
expect(istUeberfaellig(new Date('2026-03-17T06:00:00Z'), 'OFFEN', jetzt)).toBe(false)
|
||||
})
|
||||
|
||||
it('lässt Erledigtes und Verworfenes in Ruhe', () => {
|
||||
const gestern = new Date('2026-03-16T09:00:00Z')
|
||||
expect(istUeberfaellig(gestern, 'ERLEDIGT', jetzt)).toBe(false)
|
||||
expect(istUeberfaellig(gestern, 'VERWORFEN', jetzt)).toBe(false)
|
||||
})
|
||||
|
||||
it('ist ohne Termin nie überfällig', () => {
|
||||
expect(istUeberfaellig(null, 'OFFEN', jetzt)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nachDringlichkeit', () => {
|
||||
function auf(teil: Partial<SortierbareAufgabe>): SortierbareAufgabe {
|
||||
return {
|
||||
status: 'OFFEN',
|
||||
prioritaet: 3,
|
||||
faelligAm: null,
|
||||
titel: 'Titel',
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
it('stellt Offenes vor Abgeschlossenes', () => {
|
||||
expect(
|
||||
nachDringlichkeit(auf({ status: 'ERLEDIGT' }), auf({ status: 'OFFEN' })),
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stellt den früheren Termin nach vorn', () => {
|
||||
const frueh = auf({ faelligAm: new Date('2026-03-18T00:00:00Z') })
|
||||
const spaet = auf({ faelligAm: new Date('2026-03-20T00:00:00Z') })
|
||||
expect(nachDringlichkeit(frueh, spaet)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('stellt Terminiertes vor Unterminiertes', () => {
|
||||
const mitTermin = auf({ faelligAm: new Date('2026-04-01T00:00:00Z') })
|
||||
const ohneTermin = auf({ faelligAm: null })
|
||||
expect(nachDringlichkeit(mitTermin, ohneTermin)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('entscheidet bei gleichem Termin über die Priorität', () => {
|
||||
const termin = new Date('2026-03-18T00:00:00Z')
|
||||
expect(
|
||||
nachDringlichkeit(
|
||||
auf({ faelligAm: termin, prioritaet: 1 }),
|
||||
auf({ faelligAm: termin, prioritaet: 4 }),
|
||||
),
|
||||
).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('sortiert zuletzt nach dem Titel, mit deutscher Sortierung', () => {
|
||||
expect(
|
||||
nachDringlichkeit(auf({ titel: 'Äpfel' }), auf({ titel: 'Banane' })),
|
||||
).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('ergibt eine stabile Gesamtreihenfolge', () => {
|
||||
const liste: SortierbareAufgabe[] = [
|
||||
auf({ titel: 'Erledigt', status: 'ERLEDIGT' }),
|
||||
auf({ titel: 'Ohne Termin' }),
|
||||
auf({ titel: 'Übermorgen', faelligAm: new Date('2026-03-19T00:00:00Z') }),
|
||||
auf({ titel: 'Morgen', faelligAm: new Date('2026-03-18T00:00:00Z') }),
|
||||
]
|
||||
expect([...liste].sort(nachDringlichkeit).map((a) => a.titel)).toEqual([
|
||||
'Morgen',
|
||||
'Übermorgen',
|
||||
'Ohne Termin',
|
||||
'Erledigt',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fortschrittBerechnen', () => {
|
||||
it('ist bei keiner Aufgabe null Prozent', () => {
|
||||
expect(fortschrittBerechnen([])).toEqual({
|
||||
gesamt: 0,
|
||||
erledigt: 0,
|
||||
offen: 0,
|
||||
prozent: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('rechnet Erledigtes gegen die Gesamtzahl', () => {
|
||||
expect(
|
||||
fortschrittBerechnen([
|
||||
{ status: 'ERLEDIGT' },
|
||||
{ status: 'ERLEDIGT' },
|
||||
{ status: 'OFFEN' },
|
||||
{ status: 'GEPLANT' },
|
||||
]),
|
||||
).toEqual({ gesamt: 4, erledigt: 2, offen: 2, prozent: 50 })
|
||||
})
|
||||
|
||||
it('lässt Verworfenes ganz aus der Rechnung', () => {
|
||||
// Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde.
|
||||
expect(
|
||||
fortschrittBerechnen([
|
||||
{ status: 'ERLEDIGT' },
|
||||
{ status: 'VERWORFEN' },
|
||||
{ status: 'OFFEN' },
|
||||
]),
|
||||
).toEqual({ gesamt: 2, erledigt: 1, offen: 1, prozent: 50 })
|
||||
})
|
||||
|
||||
it('rundet auf ganze Prozent', () => {
|
||||
expect(
|
||||
fortschrittBerechnen([
|
||||
{ status: 'ERLEDIGT' },
|
||||
{ status: 'OFFEN' },
|
||||
{ status: 'OFFEN' },
|
||||
]).prozent,
|
||||
).toBe(33)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufwandSumme', () => {
|
||||
it('summiert und zählt Ungeschätztes als null', () => {
|
||||
expect(
|
||||
aufwandSumme([
|
||||
{ aufwandMinuten: 30 },
|
||||
{ aufwandMinuten: null },
|
||||
{ aufwandMinuten: 45 },
|
||||
]),
|
||||
).toBe(75)
|
||||
})
|
||||
|
||||
it('ist bei leerer Liste null', () => {
|
||||
expect(aufwandSumme([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufgabeSchema', () => {
|
||||
const gueltig = {
|
||||
titel: 'Angebot schreiben',
|
||||
notiz: '',
|
||||
typ: 'AUFGABE',
|
||||
status: 'OFFEN',
|
||||
prioritaet: '2',
|
||||
aufwandMinuten: '90',
|
||||
faelligAm: '2026-03-20',
|
||||
bereichId: '',
|
||||
projektId: '',
|
||||
}
|
||||
|
||||
it('nimmt ein vollständiges Formular an', () => {
|
||||
const ergebnis = aufgabeSchema.parse(gueltig)
|
||||
expect(ergebnis.titel).toBe('Angebot schreiben')
|
||||
expect(ergebnis.prioritaet).toBe(2)
|
||||
expect(ergebnis.aufwandMinuten).toBe(90)
|
||||
expect(ergebnis.faelligAm).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('macht aus leeren Feldern null statt leerer Zeichenketten', () => {
|
||||
const ergebnis = aufgabeSchema.parse(gueltig)
|
||||
expect(ergebnis.notiz).toBeNull()
|
||||
expect(ergebnis.bereichId).toBeNull()
|
||||
expect(ergebnis.projektId).toBeNull()
|
||||
})
|
||||
|
||||
it('lässt Aufwand und Termin weg, wenn nichts angegeben ist', () => {
|
||||
const ergebnis = aufgabeSchema.parse({
|
||||
...gueltig,
|
||||
aufwandMinuten: '',
|
||||
faelligAm: '',
|
||||
})
|
||||
expect(ergebnis.aufwandMinuten).toBeNull()
|
||||
expect(ergebnis.faelligAm).toBeNull()
|
||||
})
|
||||
|
||||
it('weist einen zu kurzen Titel ab', () => {
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, titel: 'A' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('hält die Priorität zwischen 1 und 5', () => {
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '0' }).success).toBe(false)
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '6' }).success).toBe(false)
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '1' }).success).toBe(true)
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '5' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('weist einen Aufwand über acht Stunden ab', () => {
|
||||
// Genau acht Stunden sind noch erlaubt, eine Minute mehr nicht.
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '480' }).success).toBe(
|
||||
true,
|
||||
)
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '481' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('weist einen Aufwand von null oder weniger ab', () => {
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '0' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('weist ein unsinniges Datum ab', () => {
|
||||
expect(aufgabeSchema.safeParse({ ...gueltig, faelligAm: 'übermorgen' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schnellerfassungSchema', () => {
|
||||
it('kommt mit einem Titel aus', () => {
|
||||
expect(schnellerfassungSchema.parse({ titel: 'Rückruf Meier' })).toEqual({
|
||||
titel: 'Rückruf Meier',
|
||||
typ: 'AUFGABE',
|
||||
})
|
||||
})
|
||||
|
||||
it('kennt auch Ideen', () => {
|
||||
expect(
|
||||
schnellerfassungSchema.parse({ titel: 'Blog aufsetzen', typ: 'IDEE' }).typ,
|
||||
).toBe('IDEE')
|
||||
})
|
||||
|
||||
it('lässt keine anderen Typen zu', () => {
|
||||
expect(
|
||||
schnellerfassungSchema.safeParse({ titel: 'Etwas', typ: 'MEILENSTEIN' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projektSchema', () => {
|
||||
it('nimmt ein Projekt an', () => {
|
||||
const ergebnis = projektSchema.parse({
|
||||
name: 'Website neu',
|
||||
beschreibung: '',
|
||||
status: 'AKTIV',
|
||||
bereichId: '',
|
||||
zielAm: '',
|
||||
})
|
||||
expect(ergebnis.name).toBe('Website neu')
|
||||
expect(ergebnis.beschreibung).toBeNull()
|
||||
expect(ergebnis.zielAm).toBeNull()
|
||||
})
|
||||
|
||||
it('weist einen zu kurzen Namen ab', () => {
|
||||
expect(
|
||||
projektSchema.safeParse({ name: 'X', beschreibung: '', status: 'AKTIV' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
AufgabenStatus,
|
||||
AufgabenTyp,
|
||||
ProjektStatus,
|
||||
Quelle,
|
||||
} from '@/generated/prisma/enums'
|
||||
import { tageDazwischen, tagesBeginn } from '@/lib/zeit'
|
||||
|
||||
/**
|
||||
* Regeln rund um Aufgaben. Reine Logik: keine Datenbank, keine Systemzeit —
|
||||
* der Zeitpunkt kommt immer als Parameter herein.
|
||||
*/
|
||||
|
||||
export const PRIORITAET_MIN = 1
|
||||
export const PRIORITAET_MAX = 5
|
||||
/** Ab hier ist eine Aufgabe zu groß für einen Zeitblock am Stück. */
|
||||
export const AUFWAND_MAX_MINUTEN = 8 * 60
|
||||
|
||||
export const TYP_NAME: Record<AufgabenTyp, string> = {
|
||||
AUFGABE: 'Aufgabe',
|
||||
IDEE: 'Idee',
|
||||
ARBEITSPAKET: 'Arbeitspaket',
|
||||
MEILENSTEIN: 'Meilenstein',
|
||||
SPIEGEL: 'Spiegel',
|
||||
}
|
||||
|
||||
export const STATUS_NAME: Record<AufgabenStatus, string> = {
|
||||
OFFEN: 'Offen',
|
||||
GEPLANT: 'Geplant',
|
||||
WARTET: 'Wartet',
|
||||
ERLEDIGT: 'Erledigt',
|
||||
VERWORFEN: 'Verworfen',
|
||||
}
|
||||
|
||||
export const PROJEKT_STATUS_NAME: Record<ProjektStatus, string> = {
|
||||
AKTIV: 'Aktiv',
|
||||
PAUSIERT: 'Pausiert',
|
||||
ABGESCHLOSSEN: 'Abgeschlossen',
|
||||
VERWORFEN: 'Verworfen',
|
||||
}
|
||||
|
||||
export const QUELLE_NAME: Record<Quelle, string> = {
|
||||
MANUELL: 'Von Hand',
|
||||
ZAMMAD: 'Zammad',
|
||||
WARTUNGSPLANER: 'Wartungsplaner',
|
||||
TELEGRAM: 'Telegram',
|
||||
SPIEGEL: 'Spiegel',
|
||||
MAIL: 'E-Mail',
|
||||
API: 'API',
|
||||
MCP: 'MCP',
|
||||
}
|
||||
|
||||
export const PRIORITAET_NAME: Record<number, string> = {
|
||||
1: 'Sofort',
|
||||
2: 'Hoch',
|
||||
3: 'Normal',
|
||||
4: 'Niedrig',
|
||||
5: 'Irgendwann',
|
||||
}
|
||||
|
||||
/** Status, in denen eine Aufgabe noch Arbeit bedeutet. */
|
||||
export const OFFENE_STATUS: readonly AufgabenStatus[] = ['OFFEN', 'GEPLANT', 'WARTET']
|
||||
|
||||
/** Status, in denen nichts mehr zu tun ist. */
|
||||
export const ABGESCHLOSSENE_STATUS: readonly AufgabenStatus[] = ['ERLEDIGT', 'VERWORFEN']
|
||||
|
||||
export function istOffen(status: AufgabenStatus): boolean {
|
||||
return OFFENE_STATUS.includes(status)
|
||||
}
|
||||
|
||||
export function istAbgeschlossen(status: AufgabenStatus): boolean {
|
||||
return ABGESCHLOSSENE_STATUS.includes(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Erlaubte Statuswechsel.
|
||||
*
|
||||
* Aus einem abgeschlossenen Zustand geht es nur zurueck nach OFFEN – wieder
|
||||
* aufgenommen wird immer am Anfang, nicht mitten im Ablauf.
|
||||
*/
|
||||
const UEBERGAENGE: Record<AufgabenStatus, readonly AufgabenStatus[]> = {
|
||||
OFFEN: ['GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'],
|
||||
GEPLANT: ['OFFEN', 'WARTET', 'ERLEDIGT', 'VERWORFEN'],
|
||||
WARTET: ['OFFEN', 'GEPLANT', 'ERLEDIGT', 'VERWORFEN'],
|
||||
ERLEDIGT: ['OFFEN'],
|
||||
VERWORFEN: ['OFFEN'],
|
||||
}
|
||||
|
||||
export function uebergangErlaubt(von: AufgabenStatus, nach: AufgabenStatus): boolean {
|
||||
if (von === nach) return true
|
||||
return UEBERGAENGE[von].includes(nach)
|
||||
}
|
||||
|
||||
export function moeglicheUebergaenge(von: AufgabenStatus): readonly AufgabenStatus[] {
|
||||
return UEBERGAENGE[von]
|
||||
}
|
||||
|
||||
// --- Faelligkeit -----------------------------------------------------------
|
||||
|
||||
export type Faelligkeit =
|
||||
| { art: 'ohne' }
|
||||
| { art: 'ueberfaellig'; tage: number }
|
||||
| { art: 'heute' }
|
||||
| { art: 'morgen' }
|
||||
| { art: 'diese-woche'; tage: number }
|
||||
| { art: 'spaeter'; tage: number }
|
||||
|
||||
/**
|
||||
* Ordnet die Faelligkeit relativ zum aktuellen Tag ein. Gerechnet wird in
|
||||
* Kalendertagen in Europe/Berlin, nicht in 24-Stunden-Schritten: was heute um
|
||||
* 23:00 faellig ist, ist heute faellig und nicht "in 0,5 Tagen".
|
||||
*/
|
||||
export function faelligkeitEinordnen(
|
||||
faelligAm: Date | null | undefined,
|
||||
jetzt: Date,
|
||||
): Faelligkeit {
|
||||
if (!faelligAm) return { art: 'ohne' }
|
||||
|
||||
const tage = tageDazwischen(jetzt, faelligAm)
|
||||
if (tage < 0) return { art: 'ueberfaellig', tage: Math.abs(tage) }
|
||||
if (tage === 0) return { art: 'heute' }
|
||||
if (tage === 1) return { art: 'morgen' }
|
||||
if (tage <= 7) return { art: 'diese-woche', tage }
|
||||
return { art: 'spaeter', tage }
|
||||
}
|
||||
|
||||
/** Kurztext fuer die Oberflaeche. */
|
||||
export function faelligkeitText(faelligkeit: Faelligkeit): string {
|
||||
switch (faelligkeit.art) {
|
||||
case 'ohne':
|
||||
return 'ohne Termin'
|
||||
case 'ueberfaellig':
|
||||
return faelligkeit.tage === 1
|
||||
? 'seit gestern überfällig'
|
||||
: `seit ${faelligkeit.tage} Tagen überfällig`
|
||||
case 'heute':
|
||||
return 'heute fällig'
|
||||
case 'morgen':
|
||||
return 'morgen fällig'
|
||||
case 'diese-woche':
|
||||
return `in ${faelligkeit.tage} Tagen fällig`
|
||||
case 'spaeter':
|
||||
return `in ${faelligkeit.tage} Tagen fällig`
|
||||
}
|
||||
}
|
||||
|
||||
export function istUeberfaellig(
|
||||
faelligAm: Date | null | undefined,
|
||||
status: AufgabenStatus,
|
||||
jetzt: Date,
|
||||
): boolean {
|
||||
if (!faelligAm || istAbgeschlossen(status)) return false
|
||||
return tagesBeginn(faelligAm).getTime() < tagesBeginn(jetzt).getTime()
|
||||
}
|
||||
|
||||
// --- Sortierung ------------------------------------------------------------
|
||||
|
||||
export type SortierbareAufgabe = {
|
||||
status: AufgabenStatus
|
||||
prioritaet: number
|
||||
faelligAm: Date | null
|
||||
titel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reihenfolge in der Liste: erst was noch offen ist, darin das Dringendste
|
||||
* zuerst. Ohne Termin heisst nicht unwichtig – solche Aufgaben landen nach
|
||||
* den terminierten, aber vor den abgeschlossenen.
|
||||
*/
|
||||
export function nachDringlichkeit(a: SortierbareAufgabe, b: SortierbareAufgabe): number {
|
||||
const aOffen = istOffen(a.status)
|
||||
const bOffen = istOffen(b.status)
|
||||
if (aOffen !== bOffen) return aOffen ? -1 : 1
|
||||
|
||||
const aTermin = a.faelligAm?.getTime() ?? null
|
||||
const bTermin = b.faelligAm?.getTime() ?? null
|
||||
if (aTermin !== bTermin) {
|
||||
if (aTermin === null) return 1
|
||||
if (bTermin === null) return -1
|
||||
return aTermin - bTermin
|
||||
}
|
||||
|
||||
if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet
|
||||
|
||||
return a.titel.localeCompare(b.titel, 'de')
|
||||
}
|
||||
|
||||
// --- Fortschritt eines Projekts -------------------------------------------
|
||||
|
||||
export type Fortschritt = {
|
||||
gesamt: number
|
||||
erledigt: number
|
||||
offen: number
|
||||
/** 0 bis 100, gerundet. Ohne Aufgaben null. */
|
||||
prozent: number
|
||||
}
|
||||
|
||||
export function fortschrittBerechnen(
|
||||
aufgaben: readonly { status: AufgabenStatus }[],
|
||||
): Fortschritt {
|
||||
// Verworfenes zaehlt gar nicht mit – sonst sieht ein Projekt fertig aus,
|
||||
// weil die Haelfte gestrichen wurde.
|
||||
const zaehlende = aufgaben.filter((a) => a.status !== 'VERWORFEN')
|
||||
const gesamt = zaehlende.length
|
||||
const erledigt = zaehlende.filter((a) => a.status === 'ERLEDIGT').length
|
||||
|
||||
return {
|
||||
gesamt,
|
||||
erledigt,
|
||||
offen: gesamt - erledigt,
|
||||
prozent: gesamt === 0 ? 0 : Math.round((erledigt / gesamt) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
/** Summe des geschaetzten Aufwands; nicht geschaetzte Aufgaben zaehlen null. */
|
||||
export function aufwandSumme(
|
||||
aufgaben: readonly { aufwandMinuten: number | null }[],
|
||||
): number {
|
||||
return aufgaben.reduce((summe, a) => summe + (a.aufwandMinuten ?? 0), 0)
|
||||
}
|
||||
|
||||
// --- Eingaben --------------------------------------------------------------
|
||||
|
||||
const leerZuNull = (wert: unknown) =>
|
||||
typeof wert === 'string' && wert.trim() === '' ? null : wert
|
||||
|
||||
export const aufgabeSchema = z.object({
|
||||
titel: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Mindestens zwei Zeichen.')
|
||||
.max(200, 'Höchstens 200 Zeichen.'),
|
||||
notiz: z.preprocess(leerZuNull, z.string().trim().max(5000).nullable().default(null)),
|
||||
typ: z.enum(['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL']),
|
||||
status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']),
|
||||
prioritaet: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(PRIORITAET_MIN, 'Priorität liegt zwischen 1 und 5.')
|
||||
.max(PRIORITAET_MAX, 'Priorität liegt zwischen 1 und 5.'),
|
||||
aufwandMinuten: z.preprocess(
|
||||
leerZuNull,
|
||||
z.coerce
|
||||
.number()
|
||||
.int('Bitte ganze Minuten angeben.')
|
||||
.min(1, 'Mindestens eine Minute.')
|
||||
.max(AUFWAND_MAX_MINUTEN, 'Mehr als acht Stunden gehört in mehrere Aufgaben.')
|
||||
.nullable()
|
||||
.default(null),
|
||||
),
|
||||
faelligAm: z.preprocess(
|
||||
leerZuNull,
|
||||
z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null),
|
||||
),
|
||||
bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
|
||||
projektId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
|
||||
})
|
||||
|
||||
export type AufgabeEingabe = z.infer<typeof aufgabeSchema>
|
||||
|
||||
/** Schnellerfassung: nur ein Titel, alles andere bekommt Standardwerte. */
|
||||
export const schnellerfassungSchema = z.object({
|
||||
titel: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Mindestens zwei Zeichen.')
|
||||
.max(200, 'Höchstens 200 Zeichen.'),
|
||||
typ: z.enum(['AUFGABE', 'IDEE']).default('AUFGABE'),
|
||||
})
|
||||
|
||||
export const projektSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Mindestens zwei Zeichen.')
|
||||
.max(80, 'Höchstens 80 Zeichen.'),
|
||||
beschreibung: z.preprocess(
|
||||
leerZuNull,
|
||||
z.string().trim().max(2000).nullable().default(null),
|
||||
),
|
||||
status: z.enum(['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN']),
|
||||
bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
|
||||
zielAm: z.preprocess(
|
||||
leerZuNull,
|
||||
z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null),
|
||||
),
|
||||
})
|
||||
|
||||
export type ProjektEingabe = z.infer<typeof projektSchema>
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'server-only'
|
||||
import type { Prisma } from '@/generated/prisma/client'
|
||||
import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums'
|
||||
import { nachDringlichkeit } from '@/lib/aufgabe'
|
||||
import { fuerAnzeige, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
|
||||
import { tagesBeginn } from '@/lib/zeit'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
/**
|
||||
* Abfragen rund um Aufgaben.
|
||||
*
|
||||
* Alle Seiten laden über diese Stelle, damit Auswahl, Sortierung und
|
||||
* Aufbereitung überall gleich sind.
|
||||
*/
|
||||
|
||||
const AUSWAHL = {
|
||||
id: true,
|
||||
titel: true,
|
||||
notiz: true,
|
||||
typ: true,
|
||||
status: true,
|
||||
prioritaet: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
quelle: true,
|
||||
quelleUrl: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
projekt: { select: { id: true, name: true } },
|
||||
} satisfies Prisma.AufgabeSelect
|
||||
|
||||
export type AufgabenFilter = {
|
||||
bereichId?: string | undefined
|
||||
projektId?: string | undefined
|
||||
status?: AufgabenStatus | undefined
|
||||
typ?: AufgabenTyp | undefined
|
||||
/** Nur Aufgaben ohne Bereich (Inbox). */
|
||||
nurInbox?: boolean | undefined
|
||||
/** Nur was noch Arbeit bedeutet. */
|
||||
nurOffen?: boolean | undefined
|
||||
/**
|
||||
* Ideen heraushalten. Sie haben ihre eigene Ansicht und sollen sich nicht
|
||||
* in die Aufgabenliste drängen — sie hätten dort keinen Termin und stünden
|
||||
* entweder unsichtbar unten oder mit erfundenem Termin oben.
|
||||
*/
|
||||
ohneIdeen?: boolean | undefined
|
||||
/** Freitext über Titel und Notiz. */
|
||||
suche?: string | undefined
|
||||
}
|
||||
|
||||
function alsWhere(filter: AufgabenFilter): Prisma.AufgabeWhereInput {
|
||||
const where: Prisma.AufgabeWhereInput = {}
|
||||
|
||||
if (filter.nurInbox) where.bereichId = null
|
||||
else if (filter.bereichId) where.bereichId = filter.bereichId
|
||||
|
||||
if (filter.projektId) where.projektId = filter.projektId
|
||||
|
||||
// Ein ausdrücklich gewählter Typ gewinnt: wer nach Ideen filtert, will sie
|
||||
// auch sehen.
|
||||
if (filter.typ) where.typ = filter.typ
|
||||
else if (filter.ohneIdeen) where.typ = { not: 'IDEE' }
|
||||
|
||||
if (filter.status) where.status = filter.status
|
||||
else if (filter.nurOffen) where.status = { in: ['OFFEN', 'GEPLANT', 'WARTET'] }
|
||||
|
||||
if (filter.suche) {
|
||||
where.OR = [
|
||||
{ titel: { contains: filter.suche, mode: 'insensitive' } },
|
||||
{ notiz: { contains: filter.suche, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Aufgaben und bringt sie in die Reihenfolge der Dringlichkeit.
|
||||
*
|
||||
* Sortiert wird bewusst in der Anwendung, nicht in SQL: die Regel ist
|
||||
* mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann
|
||||
* Titel mit deutscher Sortierung) und steht als geprüfte reine Funktion in
|
||||
* src/lib. Zwei Fassungen derselben Regel wären eine Fehlerquelle.
|
||||
*/
|
||||
export async function aufgabenLaden(
|
||||
filter: AufgabenFilter = {},
|
||||
grenze = 500,
|
||||
): Promise<AufgabeAnzeige[]> {
|
||||
const zeilen = await db.aufgabe.findMany({
|
||||
where: alsWhere(filter),
|
||||
select: AUSWAHL,
|
||||
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
|
||||
take: grenze,
|
||||
})
|
||||
|
||||
const zeitpunkt = jetzt()
|
||||
return zeilen
|
||||
.slice()
|
||||
.sort(nachDringlichkeit)
|
||||
.map((zeile) => fuerAnzeige(zeile, zeitpunkt))
|
||||
}
|
||||
|
||||
/** Bereiche zur Auswahl in Formularen und Filtern. */
|
||||
export async function bereicheLaden() {
|
||||
return db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
select: { id: true, name: true, farbe: true },
|
||||
})
|
||||
}
|
||||
|
||||
/** Projekte zur Auswahl; abgeschlossene bleiben draußen. */
|
||||
export async function projekteZurAuswahlLaden() {
|
||||
return db.projekt.findMany({
|
||||
where: { status: { in: ['AKTIV', 'PAUSIERT'] } },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
}
|
||||
|
||||
/** Zählstände für die Navigation und die Startseite. */
|
||||
export async function zaehlstaende(): Promise<{
|
||||
inbox: number
|
||||
offen: number
|
||||
ueberfaellig: number
|
||||
ideen: number
|
||||
}> {
|
||||
const zeitpunkt = jetzt()
|
||||
const heuteBeginn = tagesBeginn(zeitpunkt)
|
||||
|
||||
const [inbox, offen, ueberfaellig, ideen] = await Promise.all([
|
||||
db.aufgabe.count({
|
||||
where: {
|
||||
bereichId: null,
|
||||
typ: { not: 'IDEE' },
|
||||
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||
},
|
||||
}),
|
||||
db.aufgabe.count({
|
||||
where: { typ: { not: 'IDEE' }, status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } },
|
||||
}),
|
||||
db.aufgabe.count({
|
||||
where: {
|
||||
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||
faelligAm: { lt: heuteBeginn },
|
||||
},
|
||||
}),
|
||||
db.aufgabe.count({
|
||||
where: { typ: 'IDEE', status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } },
|
||||
}),
|
||||
])
|
||||
|
||||
return { inbox, offen, ueberfaellig, ideen }
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import type { AufgabenStatus } from '@/generated/prisma/enums'
|
||||
import {
|
||||
aufgabeSchema,
|
||||
istAbgeschlossen,
|
||||
schnellerfassungSchema,
|
||||
uebergangErlaubt,
|
||||
} from '@/lib/aufgabe'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
/**
|
||||
* Server Actions für Aufgaben.
|
||||
*
|
||||
* Jede prüft die Rolle selbst — die Seite tut es ebenfalls, aber eine Action
|
||||
* ist ein eigener Einstiegspunkt. Jede Änderung hinterlässt einen
|
||||
* Audit-Eintrag.
|
||||
*/
|
||||
|
||||
export type Rueckmeldung =
|
||||
| { ok: true; meldung: string; id?: string }
|
||||
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||
|
||||
function feldFehler(fehler: z.ZodError): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const problem of fehler.issues) {
|
||||
const feld = problem.path.join('.')
|
||||
felder[feld] ??= problem.message
|
||||
}
|
||||
return felder
|
||||
}
|
||||
|
||||
/** Prüft die Rolle und gibt bei Ablehnung eine Rückmeldung statt zu werfen. */
|
||||
async function akteurOder(
|
||||
rueckgabe: (fehler: string) => Rueckmeldung,
|
||||
): Promise<
|
||||
{ akteur: Awaited<ReturnType<typeof pruefeRolle>> } | { antwort: Rueckmeldung }
|
||||
> {
|
||||
try {
|
||||
return { akteur: await pruefeRolle('BENUTZER') }
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) return { antwort: rueckgabe(fehler.message) }
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
function alsFormularwerte(daten: FormData): Record<string, unknown> {
|
||||
return Object.fromEntries(daten.entries())
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass angegebener Bereich und Projekt wirklich existieren. Ein
|
||||
* erfundener Fremdschlüssel soll eine Meldung geben, keinen Datenbankfehler.
|
||||
*/
|
||||
async function verweiseGueltig(
|
||||
bereichId: string | null,
|
||||
projektId: string | null,
|
||||
): Promise<Record<string, string> | null> {
|
||||
const felder: Record<string, string> = {}
|
||||
|
||||
if (bereichId) {
|
||||
const bereich = await db.bereich.findUnique({
|
||||
where: { id: bereichId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!bereich) felder.bereichId = 'Diesen Bereich gibt es nicht.'
|
||||
}
|
||||
if (projektId) {
|
||||
const projekt = await db.projekt.findUnique({
|
||||
where: { id: projektId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!projekt) felder.projektId = 'Dieses Projekt gibt es nicht.'
|
||||
}
|
||||
|
||||
return Object.keys(felder).length > 0 ? felder : null
|
||||
}
|
||||
|
||||
// --- Schnellerfassung ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erfasst mit einem einzigen Feld. Ohne Bereich landet die Aufgabe in der
|
||||
* Inbox und wird später einsortiert.
|
||||
*/
|
||||
export async function schnellErfassen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = schnellerfassungSchema.safeParse({
|
||||
titel: daten.get('titel'),
|
||||
typ: daten.get('typ') ?? 'AUFGABE',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Bitte einen Titel angeben.',
|
||||
felder: feldFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: { titel: gelesen.data.titel, typ: gelesen.data.typ },
|
||||
select: { id: true, titel: true, typ: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.angelegt',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel: aufgabe.titel, typ: aufgabe.typ, weg: 'schnellerfassung' },
|
||||
})
|
||||
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/ideen')
|
||||
return {
|
||||
ok: true,
|
||||
id: aufgabe.id,
|
||||
meldung:
|
||||
gelesen.data.typ === 'IDEE' ? 'Idee notiert.' : 'Aufgabe in der Inbox abgelegt.',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Anlegen und Ändern ----------------------------------------------------
|
||||
|
||||
export async function aufgabeAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Bitte die markierten Felder prüfen.',
|
||||
felder: feldFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const verweisFehler = await verweiseGueltig(
|
||||
gelesen.data.bereichId,
|
||||
gelesen.data.projektId,
|
||||
)
|
||||
if (verweisFehler) {
|
||||
return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler }
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: {
|
||||
...gelesen.data,
|
||||
erledigtAm: istAbgeschlossen(gelesen.data.status) ? jetzt() : null,
|
||||
},
|
||||
select: { id: true, titel: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.angelegt',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel: aufgabe.titel, typ: gelesen.data.typ, status: gelesen.data.status },
|
||||
})
|
||||
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/ideen')
|
||||
revalidatePath('/projekte')
|
||||
return { ok: true, id: aufgabe.id, meldung: `„${aufgabe.titel}" angelegt.` }
|
||||
}
|
||||
|
||||
export async function aufgabeAendern(id: string, daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Bitte die markierten Felder prüfen.',
|
||||
felder: feldFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const vorher = await db.aufgabe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, titel: true, status: true, erledigtAm: true },
|
||||
})
|
||||
if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
|
||||
|
||||
if (!uebergangErlaubt(vorher.status, gelesen.data.status)) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler:
|
||||
'Dieser Statuswechsel ist nicht vorgesehen. Abgeschlossenes wird zuerst wieder geöffnet.',
|
||||
felder: { status: 'Nicht möglich von hier aus.' },
|
||||
}
|
||||
}
|
||||
|
||||
const verweisFehler = await verweiseGueltig(
|
||||
gelesen.data.bereichId,
|
||||
gelesen.data.projektId,
|
||||
)
|
||||
if (verweisFehler) {
|
||||
return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler }
|
||||
}
|
||||
|
||||
const wirdAbgeschlossen =
|
||||
istAbgeschlossen(gelesen.data.status) && !istAbgeschlossen(vorher.status)
|
||||
const wirdGeoeffnet =
|
||||
!istAbgeschlossen(gelesen.data.status) && istAbgeschlossen(vorher.status)
|
||||
|
||||
await db.aufgabe.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...gelesen.data,
|
||||
...(wirdAbgeschlossen ? { erledigtAm: jetzt() } : {}),
|
||||
...(wirdGeoeffnet ? { erledigtAm: null } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.geaendert',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: id,
|
||||
details: {
|
||||
titel: gelesen.data.titel,
|
||||
statusVorher: vorher.status,
|
||||
statusNachher: gelesen.data.status,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/ideen')
|
||||
revalidatePath('/projekte')
|
||||
return { ok: true, id, meldung: `„${gelesen.data.titel}" gespeichert.` }
|
||||
}
|
||||
|
||||
// --- Kleine Handgriffe -----------------------------------------------------
|
||||
|
||||
const statusEingabe = z.object({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']),
|
||||
})
|
||||
|
||||
/** Statuswechsel mit einem Klick, etwa das Abhaken in der Liste. */
|
||||
export async function statusSetzen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = statusEingabe.safeParse({
|
||||
id: daten.get('id'),
|
||||
status: daten.get('status'),
|
||||
})
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
|
||||
const vorher = await db.aufgabe.findUnique({
|
||||
where: { id: gelesen.data.id },
|
||||
select: { id: true, titel: true, status: true },
|
||||
})
|
||||
if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
|
||||
|
||||
const neu: AufgabenStatus = gelesen.data.status
|
||||
if (!uebergangErlaubt(vorher.status, neu)) {
|
||||
return { ok: false, fehler: 'Dieser Statuswechsel ist nicht vorgesehen.' }
|
||||
}
|
||||
|
||||
await db.aufgabe.update({
|
||||
where: { id: vorher.id },
|
||||
data: {
|
||||
status: neu,
|
||||
erledigtAm: istAbgeschlossen(neu) ? jetzt() : null,
|
||||
},
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.status',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: vorher.id,
|
||||
details: { titel: vorher.titel, vorher: vorher.status, nachher: neu },
|
||||
})
|
||||
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/ideen')
|
||||
revalidatePath('/projekte')
|
||||
return { ok: true, meldung: `„${vorher.titel}" ist jetzt ${neu.toLowerCase()}.` }
|
||||
}
|
||||
|
||||
const einsortierenEingabe = z.object({
|
||||
id: z.string().min(1),
|
||||
bereichId: z.string().min(1),
|
||||
})
|
||||
|
||||
/** Aus der Inbox in einen Bereich schieben. */
|
||||
export async function einsortieren(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = einsortierenEingabe.safeParse({
|
||||
id: daten.get('id'),
|
||||
bereichId: daten.get('bereichId'),
|
||||
})
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Bitte einen Bereich wählen.' }
|
||||
|
||||
const [aufgabe, bereich] = await Promise.all([
|
||||
db.aufgabe.findUnique({
|
||||
where: { id: gelesen.data.id },
|
||||
select: { id: true, titel: true },
|
||||
}),
|
||||
db.bereich.findUnique({
|
||||
where: { id: gelesen.data.bereichId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
|
||||
if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht.' }
|
||||
|
||||
await db.aufgabe.update({
|
||||
where: { id: aufgabe.id },
|
||||
data: { bereichId: bereich.id },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.einsortiert',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel: aufgabe.titel, bereich: bereich.name },
|
||||
})
|
||||
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/aufgaben')
|
||||
return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` }
|
||||
}
|
||||
|
||||
/** Aus einer Idee wird eine Aufgabe. */
|
||||
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const id = daten.get('id')
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
}
|
||||
|
||||
const idee = await db.aufgabe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, titel: true, typ: true },
|
||||
})
|
||||
if (!idee) return { ok: false, fehler: 'Diese Idee gibt es nicht mehr.' }
|
||||
if (idee.typ !== 'IDEE') {
|
||||
return { ok: false, fehler: 'Das ist keine Idee.' }
|
||||
}
|
||||
|
||||
await db.aufgabe.update({
|
||||
where: { id: idee.id },
|
||||
data: { typ: 'AUFGABE', status: 'OFFEN', erledigtAm: null },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.aus-idee',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: idee.id,
|
||||
details: { titel: idee.titel },
|
||||
})
|
||||
|
||||
revalidatePath('/ideen')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/inbox')
|
||||
return { ok: true, meldung: `„${idee.titel}" ist jetzt eine Aufgabe.` }
|
||||
}
|
||||
|
||||
// --- Löschen ---------------------------------------------------------------
|
||||
|
||||
export async function aufgabeLoeschen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const id = daten.get('id')
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, titel: true, typ: true },
|
||||
})
|
||||
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
|
||||
|
||||
await db.aufgabe.delete({ where: { id: aufgabe.id } })
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'aufgabe.geloescht',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel: aufgabe.titel, typ: aufgabe.typ },
|
||||
})
|
||||
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/inbox')
|
||||
revalidatePath('/ideen')
|
||||
revalidatePath('/projekte')
|
||||
return { ok: true, meldung: `„${aufgabe.titel}" gelöscht.` }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { z } from 'zod'
|
||||
import { projektSchema } from '@/lib/aufgabe'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export type Rueckmeldung =
|
||||
| { ok: true; meldung: string; id?: string }
|
||||
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||
|
||||
function feldFehler(fehler: z.ZodError): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const problem of fehler.issues) {
|
||||
const feld = problem.path.join('.')
|
||||
felder[feld] ??= problem.message
|
||||
}
|
||||
return felder
|
||||
}
|
||||
|
||||
async function akteur() {
|
||||
return pruefeRolle('BENUTZER')
|
||||
}
|
||||
|
||||
export async function projektAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||
let ich
|
||||
try {
|
||||
ich = await akteur()
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||
throw fehler
|
||||
}
|
||||
|
||||
const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries()))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Bitte die markierten Felder prüfen.',
|
||||
felder: feldFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const vorhanden = await db.projekt.findUnique({
|
||||
where: { name: gelesen.data.name },
|
||||
select: { id: true },
|
||||
})
|
||||
if (vorhanden) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Diesen Namen gibt es schon.',
|
||||
felder: { name: 'Diesen Namen gibt es schon.' },
|
||||
}
|
||||
}
|
||||
|
||||
const projekt = await db.projekt.create({
|
||||
data: gelesen.data,
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: ich.id, email: ich.email },
|
||||
aktion: 'projekt.angelegt',
|
||||
objektTyp: 'Projekt',
|
||||
objektId: projekt.id,
|
||||
details: { name: projekt.name },
|
||||
})
|
||||
|
||||
revalidatePath('/projekte')
|
||||
return { ok: true, id: projekt.id, meldung: `Projekt „${projekt.name}" angelegt.` }
|
||||
}
|
||||
|
||||
export async function projektAendern(id: string, daten: FormData): Promise<Rueckmeldung> {
|
||||
let ich
|
||||
try {
|
||||
ich = await akteur()
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||
throw fehler
|
||||
}
|
||||
|
||||
const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries()))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Bitte die markierten Felder prüfen.',
|
||||
felder: feldFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const vorher = await db.projekt.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, status: true },
|
||||
})
|
||||
if (!vorher) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' }
|
||||
|
||||
const dublette = await db.projekt.findFirst({
|
||||
where: { name: gelesen.data.name, id: { not: id } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (dublette) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler: 'Diesen Namen gibt es schon.',
|
||||
felder: { name: 'Diesen Namen gibt es schon.' },
|
||||
}
|
||||
}
|
||||
|
||||
await db.projekt.update({ where: { id }, data: gelesen.data })
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: ich.id, email: ich.email },
|
||||
aktion: 'projekt.geaendert',
|
||||
objektTyp: 'Projekt',
|
||||
objektId: id,
|
||||
details: {
|
||||
vorher: vorher.name,
|
||||
nachher: gelesen.data.name,
|
||||
statusVorher: vorher.status,
|
||||
statusNachher: gelesen.data.status,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/projekte')
|
||||
revalidatePath(`/projekte/${id}`)
|
||||
return { ok: true, id, meldung: `Projekt „${gelesen.data.name}" gespeichert.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Projekt. Die enthaltenen Aufgaben bleiben bestehen und verlieren
|
||||
* nur ihre Zuordnung — sonst verschwände mit dem Projekt auch die Arbeit.
|
||||
*/
|
||||
export async function projektLoeschen(daten: FormData): Promise<Rueckmeldung> {
|
||||
let ich
|
||||
try {
|
||||
ich = await akteur()
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||
throw fehler
|
||||
}
|
||||
|
||||
const id = daten.get('id')
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
}
|
||||
|
||||
const projekt = await db.projekt.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, _count: { select: { aufgaben: true } } },
|
||||
})
|
||||
if (!projekt) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' }
|
||||
|
||||
await db.projekt.delete({ where: { id: projekt.id } })
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: ich.id, email: ich.email },
|
||||
aktion: 'projekt.geloescht',
|
||||
objektTyp: 'Projekt',
|
||||
objektId: projekt.id,
|
||||
details: { name: projekt.name, aufgabenGeloest: projekt._count.aufgaben },
|
||||
})
|
||||
|
||||
revalidatePath('/projekte')
|
||||
revalidatePath('/aufgaben')
|
||||
return {
|
||||
ok: true,
|
||||
meldung:
|
||||
projekt._count.aufgaben > 0
|
||||
? `Projekt „${projekt.name}" gelöscht. ${projekt._count.aufgaben} Aufgabe(n) bleiben erhalten.`
|
||||
: `Projekt „${projekt.name}" gelöscht.`,
|
||||
}
|
||||
}
|
||||