Compare commits
@@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer
|
|||||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Kalenderanbieter ------------------------------------------------------
|
||||||
|
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
|
||||||
|
# erst, wenn Kennung UND Geheimnis gesetzt sind.
|
||||||
|
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
|
||||||
|
# Rueckleit-URIs:
|
||||||
|
# <APP_URL>/api/kalender/rueckleitung/graph
|
||||||
|
# <APP_URL>/api/kalender/rueckleitung/google
|
||||||
|
GRAPH_CLIENT_ID=
|
||||||
|
GRAPH_CLIENT_SECRET=
|
||||||
|
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||||
|
GRAPH_TENANT_ID=organizations
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
# --- Sonstiges -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -6,6 +6,96 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.4.0] – 2026-09-11
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
|
||||||
|
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
|
||||||
|
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
|
||||||
|
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
|
||||||
|
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
|
||||||
|
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
|
||||||
|
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
|
||||||
|
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
|
||||||
|
werden rot, sperren aber nichts.
|
||||||
|
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
|
||||||
|
wieder zurück, wenn nichts mehr für sie geplant ist.
|
||||||
|
- Handbuchseite zur Wochenplanung.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
|
||||||
|
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
|
||||||
|
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
|
||||||
|
endlos hinterherschreiben.
|
||||||
|
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
|
||||||
|
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
|
||||||
|
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
|
||||||
|
fremden Stand zu holen.
|
||||||
|
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
|
||||||
|
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-09-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
|
||||||
|
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
|
||||||
|
`daheim`; in `firma` liefert die Route 404.
|
||||||
|
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
|
||||||
|
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
|
||||||
|
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
|
||||||
|
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
|
||||||
|
allen verbundenen Kalendern gegen die Tageskapazität.
|
||||||
|
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
|
||||||
|
abgelaufene WebAuthn-Herausforderungen entfernen.
|
||||||
|
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
|
||||||
|
und die Google Cloud Console in der README.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
|
||||||
|
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
|
||||||
|
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
|
||||||
|
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
|
||||||
|
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
|
||||||
|
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
|
||||||
|
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
|
||||||
|
enthält das Client-Secret oft im Echo.
|
||||||
|
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
|
||||||
|
Termininhalte.
|
||||||
|
|
||||||
|
## [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
|
## [0.1.0] – 2026-09-10
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
|||||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||||
wird.
|
wird.
|
||||||
|
|
||||||
> **Stand:** Meilenstein 1 von 8. Die Grundlage steht: Anmeldung, Rollen,
|
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||||
> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der
|
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||||
> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt,
|
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
|
||||||
> was wann folgt.
|
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
|
||||||
|
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||||
|
|
||||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||||
zur ersten Anmeldung
|
zur ersten Anmeldung
|
||||||
@@ -22,6 +23,16 @@ wird.
|
|||||||
|
|
||||||
### Heute
|
### 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
|
||||||
|
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||||
|
Bereich, wie voll der heutige Tag schon ist
|
||||||
|
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
||||||
|
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
||||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -32,19 +43,24 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen
|
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||||
und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und
|
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
| | Hell | Dunkel |
|
| | Hell | Dunkel |
|
||||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
| Anmelden |  |  |
|
| Anmelden |  |  |
|
||||||
| Start |  |  |
|
| Start |  |  |
|
||||||
| Bereiche |  |  |
|
| Inbox |  |  |
|
||||||
| Einstellungen |  |  |
|
| Woche |  |  |
|
||||||
| Protokoll |  |  |
|
| Aufgaben |  |  |
|
||||||
|
| Projekte |  |  |
|
||||||
|
| Ideen |  |  |
|
||||||
|
| Kalender |  |  |
|
||||||
|
| Bereiche |  |  |
|
||||||
|
| Einstellungen |  |  |
|
||||||
|
| Protokoll |  |  |
|
||||||
|
|
||||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||||
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||||
@@ -107,6 +123,19 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
|||||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||||
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||||
|
|
||||||
|
### Kalenderanbieter
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ---------------------- | --------------- | ------------------------------------------------------------- |
|
||||||
|
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
|
||||||
|
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
|
||||||
|
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
|
||||||
|
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
|
||||||
|
|
||||||
|
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
||||||
|
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
|
||||||
|
|
||||||
### Einrichtung und Sonstiges
|
### Einrichtung und Sonstiges
|
||||||
|
|
||||||
| Variable | Standard | Bedeutung |
|
| Variable | Standard | Bedeutung |
|
||||||
@@ -116,6 +145,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
|||||||
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||||
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
||||||
|
|
||||||
|
## Kalenderanbieter einrichten
|
||||||
|
|
||||||
|
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
|
||||||
|
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
|
||||||
|
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
|
||||||
|
Berechtigung von Anfang an.
|
||||||
|
|
||||||
|
### Microsoft 365 (Entra ID)
|
||||||
|
|
||||||
|
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
|
||||||
|
App-Registrierungen → Neue Registrierung**.
|
||||||
|
2. Name frei wählen, etwa „Planer".
|
||||||
|
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
|
||||||
|
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
|
||||||
|
**Konten in einem beliebigen Organisationsverzeichnis** (dann
|
||||||
|
`organizations` belassen).
|
||||||
|
4. Umleitungs-URI, Plattform **Web**:
|
||||||
|
`<APP_URL>/api/kalender/rueckleitung/graph`
|
||||||
|
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
|
||||||
|
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
|
||||||
|
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
|
||||||
|
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
|
||||||
|
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
|
||||||
|
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
|
||||||
|
`openid`, `email`.
|
||||||
|
|
||||||
|
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
|
||||||
|
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
|
||||||
|
|
||||||
|
### Google Kalender
|
||||||
|
|
||||||
|
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
|
||||||
|
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
|
||||||
|
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
|
||||||
|
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
|
||||||
|
Testbenutzer.
|
||||||
|
4. Bereiche (Scopes) hinzufügen:
|
||||||
|
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
|
||||||
|
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
|
||||||
|
Anwendungstyp **Webanwendung**.
|
||||||
|
6. Autorisierter Weiterleitungs-URI:
|
||||||
|
`<APP_URL>/api/kalender/rueckleitung/google`
|
||||||
|
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
|
||||||
|
`GOOGLE_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
|
||||||
|
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
|
||||||
|
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
|
||||||
|
|
||||||
|
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
|
||||||
|
verbinden. Die Anleitung für den Alltag steht im
|
||||||
|
[Handbuch](docs/handbuch/kalenderkonten.md).
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
### Starten und stoppen
|
### Starten und stoppen
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| Meilenstein | Ziel | Status |
|
| Meilenstein | Ziel | Status |
|
||||||
| --------------------------------------- | ---------------------------- | -------- |
|
| --------------------------------------- | ---------------------------- | -------- |
|
||||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant |
|
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||||
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
|
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||||
@@ -45,38 +45,52 @@ wirklich braucht, statt heute geraten.
|
|||||||
|
|
||||||
## M2 – Aufgaben und Projekte
|
## M2 – Aufgaben und Projekte
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.2.0.**
|
||||||
|
|
||||||
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
||||||
|
|
||||||
- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
- [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
||||||
- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
- [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
||||||
- [ ] Inbox: schnell erfassen, später einsortieren
|
- [x] Inbox: schnell erfassen, später einsortieren
|
||||||
- [ ] Ideen getrennt von Aufgaben
|
- [x] Ideen getrennt von Aufgaben
|
||||||
- [ ] Projekte mit Arbeitspaketen und Meilensteinen
|
- [x] Projekte mit Arbeitspaketen und Meilensteinen
|
||||||
- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
- [x] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
||||||
- [ ] Handbuchseiten und Bilder
|
- [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
|
## M3 – Kalender lesen
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||||
|
|
||||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||||
|
|
||||||
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||||
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||||
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||||
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||||
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||||
- [ ] Hintergrundjob mit node-cron
|
- [x] Hintergrundjob mit node-cron
|
||||||
|
|
||||||
## M4 – Wochenplanung und Zeitblöcke
|
## M4 – Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**Erledigt am 11.09.2026 mit Version 0.4.0.**
|
||||||
|
|
||||||
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
||||||
Verschiebungen von dort zurückholen.
|
Verschiebungen von dort zurückholen.
|
||||||
|
|
||||||
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||||
- [ ] Wochenansicht mit `@dnd-kit/core`
|
- [x] Wochenansicht mit `@dnd-kit/core`
|
||||||
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
|
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||||
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||||
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||||
- [ ] Belegung gegen die Tageskapazität des Bereichs
|
- [x] Belegung gegen die Tageskapazität des Bereichs
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
|
||||||
|
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
|
||||||
|
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
|
||||||
|
|
||||||
## M5 – Benachrichtigungen
|
## M5 – Benachrichtigungen
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,266 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
|||||||
| Meilenstein | Art | Minuten | Stunden |
|
| Meilenstein | Art | Minuten | Stunden |
|
||||||
| ----------- | ------ | ------- | -------- |
|
| ----------- | ------ | ------- | -------- |
|
||||||
| M1 | claude | 161 | 2,68 |
|
| M1 | claude | 161 | 2,68 |
|
||||||
| **Gesamt** | | **161** | **2,68** |
|
| M2 | claude | 44 | 0,73 |
|
||||||
|
| M3 | claude | 41 | 0,68 |
|
||||||
|
| M4 | claude | 167 | 2,78 |
|
||||||
|
| **Gesamt** | | **413** | **6,88** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M4: Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**Dauer:** 167 Minuten (20:42–23:29), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Die Kernidee des Projekts — Aufgaben per Ziehen und
|
||||||
|
Ablegen in echte Kalendertermine verwandeln, mit Rückweg.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- `Zeitblock` samt Migration und Schleifenschutz (30 Tests)
|
||||||
|
- Schreibweg zu Graph und Google mit `If-Match`, Rückweg im Abgleich
|
||||||
|
- Rasterlogik für die Wochenansicht (21 Tests)
|
||||||
|
- Wochenansicht mit `@dnd-kit/core`: einplanen, verschieben, ausplanen
|
||||||
|
- Kapazität und Belegung je Wochentag
|
||||||
|
- 3 neue E2E-Tests, Handbuchseite
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Der Fingerabdruck geht nur über Beginn und Ende.** Titel und Beschreibung
|
||||||
|
ändern sich auch aus harmlosen Gründen — etwa weil der Aufgabentitel
|
||||||
|
bearbeitet wurde. Für die Planung zählt allein, wann der Block liegt; alles
|
||||||
|
andere in den Fingerabdruck zu nehmen hieße, den Anbieter grundlos zu
|
||||||
|
behelligen.
|
||||||
|
|
||||||
|
**Der Kalender gewinnt, ausnahmslos.** Wer im Kalender etwas verschiebt,
|
||||||
|
meint es so. Säße der Planer am längeren Hebel, zöge er dem Benutzer den
|
||||||
|
Termin wieder weg — und der wüsste nicht, warum. Bei HTTP 412 verwirft der
|
||||||
|
Planer sogar seinen eigenen Fingerabdruck, damit der nächste Abgleich die
|
||||||
|
fremde Änderung sicher als solche erkennt.
|
||||||
|
|
||||||
|
**Scheitert das Schreiben, überlebt die Planung.** Der Zeitblock bleibt mit
|
||||||
|
vermerktem Fehler stehen. Ihn zu verwerfen hieße, die Planung des Benutzers
|
||||||
|
wegen eines Netzproblems zu vergessen; das nächste Verschieben trägt ihn
|
||||||
|
nach. Beim Löschen ist es umgekehrt: Da verschwindet der Zeitblock auch bei
|
||||||
|
unerreichbarem Kalender, sonst wäre die Planung blockiert.
|
||||||
|
|
||||||
|
**Die Dauer kommt aus dem geschätzten Aufwand.** Wer schätzt, bekommt die
|
||||||
|
passende Blockgröße geschenkt. Das belohnt genau die Gewohnheit, die die
|
||||||
|
Planung überhaupt erst tragfähig macht.
|
||||||
|
|
||||||
|
**Ziehen beginnt erst nach acht Pixeln.** Ohne diese Schwelle löst jeder
|
||||||
|
Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
|
||||||
|
|
||||||
|
**Überbuchte Tage werden rot, aber nicht gesperrt.** Der Planer ist ein
|
||||||
|
Werkzeug, kein Aufpasser. Manchmal muss ein Tag überbucht sein.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Ein Zeitblock steht auch im Terminspiegel und erschien doppelt — einmal als eigene Planung, einmal als fremder Termin | Die Wochenabfrage filtert Termine heraus, deren `extId` zu einem Zeitblock gehört. |
|
||||||
|
| Ein Build lief 32 Minuten statt Sekunden | Systemlast durch einen gleichzeitigen Docker-Bau. Ein sauberer Neubau danach: 46 Sekunden. Kein Codeproblem. |
|
||||||
|
| Sich überschneidende Termine lagen unsichtbar übereinander | `spurenVerteilen` legt sie nebeneinander und gibt eine Spur wieder frei, sobald sie zu Ende ist. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Der Schreibweg ist nicht gegen die echten APIs geprüft.** Wie in M3
|
||||||
|
fehlen die Zugangsdaten. Die Logik ist getestet, der erste echte Schreib-
|
||||||
|
und Rückweg ist Florians.
|
||||||
|
- Die Dauer lässt sich nicht am Balken ziehen; wer eine andere will, ändert
|
||||||
|
den Aufwand der Aufgabe.
|
||||||
|
- Ein Zeitblock landet immer im ersten verbundenen Kalender. Das
|
||||||
|
Standard-Kalenderkonto je Bereich ist im Datenmodell vorbereitet, aber
|
||||||
|
noch nicht in der Oberfläche.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 5791047 feat(zeitbloecke): Datenmodell und Schleifenschutz
|
||||||
|
- 9c1c916 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
|
||||||
|
- bcb4b8e feat(woche): Rasterlogik für die Wochenansicht
|
||||||
|
- fada445 feat(woche): Wochenansicht mit Ziehen und Ablegen
|
||||||
|
- 5b1e998 test: Wochenansicht und Ausplanen absichern
|
||||||
|
- 0974695 docs: Handbuchseite zur Wochenplanung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M3: Kalender lesen
|
||||||
|
|
||||||
|
**Dauer:** 41 Minuten (17:48–18:29), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der
|
||||||
|
Planer weiß, wann überhaupt Platz ist.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein
|
||||||
|
Standard-Kalenderkonto für M4
|
||||||
|
- Tokenverschlüsselung mit AES-256-GCM
|
||||||
|
- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form
|
||||||
|
gebracht, geprüft gegen anonymisierte Beispielantworten
|
||||||
|
- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4
|
||||||
|
- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf
|
||||||
|
einen vollständigen Durchlauf
|
||||||
|
- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite
|
||||||
|
- Hintergrundjobs für Abgleich und nächtliches Aufräumen
|
||||||
|
- 68 neue Unit-Tests, 10 neue Integrationsprüfungen
|
||||||
|
- Handbuchseite und Einrichtungsanleitung für Entra ID und Google
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die
|
||||||
|
Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf
|
||||||
|
fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die
|
||||||
|
nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere
|
||||||
|
Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet
|
||||||
|
nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss
|
||||||
|
die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig.
|
||||||
|
|
||||||
|
**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und
|
||||||
|
Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an;
|
||||||
|
kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine
|
||||||
|
um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein
|
||||||
|
Kalender, der leise falsch liegt.
|
||||||
|
|
||||||
|
**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel
|
||||||
|
blockiert als eine Doppelbuchung.
|
||||||
|
|
||||||
|
**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt.
|
||||||
|
Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung
|
||||||
|
scheitert — einmal breiter fragen ist das kleinere Übel.
|
||||||
|
|
||||||
|
**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder
|
||||||
|
Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit
|
||||||
|
kostet.
|
||||||
|
|
||||||
|
**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit
|
||||||
|
ohnehin, gleich zu welchem Lebensbereich er gehört.
|
||||||
|
|
||||||
|
**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und
|
||||||
|
die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem
|
||||||
|
Abhängigkeiten und blinde Flecken.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. |
|
||||||
|
| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. |
|
||||||
|
| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. |
|
||||||
|
| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für
|
||||||
|
Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte
|
||||||
|
Beispielantworten geprüft; den ersten echten Verbindungsversuch muss
|
||||||
|
Florian machen. Die Anleitung dafür steht in der README.
|
||||||
|
- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind
|
||||||
|
im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche.
|
||||||
|
- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten
|
||||||
|
wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das
|
||||||
|
einfachere Verhalten.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine
|
||||||
|
- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
|
||||||
|
- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google
|
||||||
|
- 0c67b73 feat(kalender): Seite für die Kalenderkonten
|
||||||
|
- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
|
||||||
|
- d8d3227 feat(kalender): Belegung des Tages auf der Startseite
|
||||||
|
- df50627 test: Verschlüsselung und Kalendermodelle absichern
|
||||||
|
- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,5 @@
|
|||||||
datum;start;ende;minuten;meilenstein;art;notiz
|
datum;start;ende;minuten;meilenstein;art;notiz
|
||||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
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
|
||||||
|
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
|
||||||
|
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
|
||||||
|
|||||||
|
@@ -133,14 +133,26 @@ E-Mail-Adresse und **Mit Passkey anmelden**.
|
|||||||
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
||||||
> du dich aus, wenn ein Gerät verlorengeht.
|
> 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
|
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
||||||
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
||||||
|
|
||||||
## Wie es weitergeht
|
## 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
|
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
||||||
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
- [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: 267 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -9,9 +9,21 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
||||||
Tageskapazität festlegen
|
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
|
||||||
|
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||||
|
verbinden, Belegung verstehen
|
||||||
|
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
|
Kalender legen
|
||||||
|
|
||||||
## Verwaltung
|
## Verwaltung
|
||||||
|
|
||||||
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
## Was noch kommt
|
## Was noch kommt
|
||||||
|
|
||||||
@@ -19,10 +31,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ------------------------------- | ----------- |
|
||||||
| Aufgaben erfassen, Inbox, Ideen | M2 |
|
|
||||||
| Projekte und Arbeitspakete | M2 |
|
|
||||||
| Kalenderkonten verbinden | M3 |
|
|
||||||
| Wochenplanung und Zeitblöcke | M4 |
|
|
||||||
| Benachrichtigungen | M5 |
|
| Benachrichtigungen | M5 |
|
||||||
| Integrationen | M6 |
|
| Integrationen | M6 |
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
| Schnellerfassung vom Handy, API | M7 |
|
||||||
|
|||||||
@@ -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,116 @@
|
|||||||
|
# Kalenderkonten verbinden
|
||||||
|
|
||||||
|
Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**.
|
||||||
|
Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Was der Planer liest — und was nicht
|
||||||
|
|
||||||
|
| Liest er | Nutzt er wofür |
|
||||||
|
| --------------------------------- | ------------------------------------------------ |
|
||||||
|
| Beginn und Ende | Belegung gegen die Tageskapazität |
|
||||||
|
| Betreff | Anzeige in der Wochenansicht |
|
||||||
|
| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung |
|
||||||
|
| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht |
|
||||||
|
|
||||||
|
**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte.
|
||||||
|
Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie
|
||||||
|
landen aber **niemals in Protokollen**.
|
||||||
|
|
||||||
|
Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus
|
||||||
|
diesem Fenster herauswandert, wird nachts wieder entfernt.
|
||||||
|
|
||||||
|
## Einen Kalender verbinden
|
||||||
|
|
||||||
|
1. Im Menü auf **Kalender**.
|
||||||
|
2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**.
|
||||||
|
3. Beim Anbieter anmelden und der Berechtigung zustimmen.
|
||||||
|
4. Du landest wieder hier, und der erste Abgleich läuft sofort.
|
||||||
|
|
||||||
|
> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4
|
||||||
|
> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte
|
||||||
|
> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau
|
||||||
|
> daran scheitert Einrichtung erfahrungsgemäß.
|
||||||
|
|
||||||
|
### Welche Anbieter es gibt
|
||||||
|
|
||||||
|
Das hängt an der Instanz und ist nicht verhandelbar:
|
||||||
|
|
||||||
|
| | Firma | Daheim |
|
||||||
|
| ------------- | -------- | ------ |
|
||||||
|
| Microsoft 365 | ja | ja |
|
||||||
|
| Google | **nein** | ja |
|
||||||
|
|
||||||
|
In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort
|
||||||
|
mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler.
|
||||||
|
|
||||||
|
## Der Abgleich
|
||||||
|
|
||||||
|
Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen**
|
||||||
|
stößt du ihn von Hand an.
|
||||||
|
|
||||||
|
Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht
|
||||||
|
jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer
|
||||||
|
Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer
|
||||||
|
einem etwas längeren Abgleich nichts.
|
||||||
|
|
||||||
|
## Die Belegung
|
||||||
|
|
||||||
|
Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist:
|
||||||
|
Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses
|
||||||
|
Bereichs.
|
||||||
|
|
||||||
|
> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die
|
||||||
|
> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört.
|
||||||
|
> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die
|
||||||
|
> Firma tun.
|
||||||
|
|
||||||
|
Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin
|
||||||
|
um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um
|
||||||
|
17 Uhr endet.
|
||||||
|
|
||||||
|
Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur
|
||||||
|
einmal.
|
||||||
|
|
||||||
|
## Ein Konto umbenennen
|
||||||
|
|
||||||
|
Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich
|
||||||
|
besser als die Mailadresse, gerade wenn du mehrere Konten desselben
|
||||||
|
Anbieters hast.
|
||||||
|
|
||||||
|
## Ein Konto trennen
|
||||||
|
|
||||||
|
Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei —
|
||||||
|
**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4
|
||||||
|
ohnehin nur gelesen.
|
||||||
|
|
||||||
|
Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen:
|
||||||
|
|
||||||
|
- Microsoft: <https://myapps.microsoft.com> → _Zugriff verwalten_
|
||||||
|
- Google: <https://myaccount.google.com/permissions>
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. |
|
||||||
|
| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. |
|
||||||
|
| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. |
|
||||||
|
| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. |
|
||||||
|
| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. |
|
||||||
|
| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM).
|
||||||
|
Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange
|
||||||
|
er nicht auch `AUTH_SECRET` hat.
|
||||||
|
|
||||||
|
Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden
|
||||||
|
werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen
|
||||||
|
ungültig.
|
||||||
|
|
||||||
|
Für die Einrichtung auf Serverseite — was in Entra ID und in der Google
|
||||||
|
Cloud Console anzulegen ist — siehe
|
||||||
|
[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten).
|
||||||
@@ -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 |
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
|
||||||
|
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Der Aufbau
|
||||||
|
|
||||||
|
| Wo | Was |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
|
||||||
|
| **Rechts** | Sieben Tage im Viertelstundenraster |
|
||||||
|
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
|
||||||
|
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
|
||||||
|
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
|
||||||
|
|
||||||
|
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
|
||||||
|
Bereiche, mit einer Stunde Luft nach oben und unten.
|
||||||
|
|
||||||
|
## Eine Aufgabe einplanen
|
||||||
|
|
||||||
|
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
|
||||||
|
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
|
||||||
|
Kalender.
|
||||||
|
|
||||||
|
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
|
||||||
|
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
|
||||||
|
> es 60 Minuten, die du danach anpassen kannst.
|
||||||
|
|
||||||
|
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
|
||||||
|
|
||||||
|
## Verschieben
|
||||||
|
|
||||||
|
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
|
||||||
|
Kalendertermin nach.
|
||||||
|
|
||||||
|
## Ausplanen
|
||||||
|
|
||||||
|
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
|
||||||
|
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
|
||||||
|
ist.
|
||||||
|
|
||||||
|
## Wenn du im Kalender verschiebst
|
||||||
|
|
||||||
|
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
|
||||||
|
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
|
||||||
|
|
||||||
|
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
|
||||||
|
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
|
||||||
|
> und du wüsstest nicht, warum.
|
||||||
|
>
|
||||||
|
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
|
||||||
|
> Aufgabe selbst bleibt.
|
||||||
|
|
||||||
|
### Warum sich das nicht aufschaukelt
|
||||||
|
|
||||||
|
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
|
||||||
|
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
|
||||||
|
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
|
||||||
|
ab, hat jemand anderes verschoben.
|
||||||
|
|
||||||
|
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
|
||||||
|
|
||||||
|
## Das Warnzeichen im Block
|
||||||
|
|
||||||
|
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
|
||||||
|
gerade nicht erreichbar.
|
||||||
|
|
||||||
|
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
|
||||||
|
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
|
||||||
|
Planer ihn nach.
|
||||||
|
|
||||||
|
## Die Zahl über der Spalte
|
||||||
|
|
||||||
|
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
|
||||||
|
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
|
||||||
|
|
||||||
|
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
|
||||||
|
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
|
||||||
|
|
||||||
|
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
|
||||||
|
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||||
|
|
||||||
|
## Blättern
|
||||||
|
|
||||||
|
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
|
||||||
|
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
|
||||||
|
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
|
||||||
|
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
|
||||||
|
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
|
||||||
|
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
|
||||||
|
|
||||||
|
## Was noch nicht geht
|
||||||
|
|
||||||
|
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
|
||||||
|
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
|
||||||
|
Oberfläche.
|
||||||
|
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
|
||||||
|
ändert den Aufwand der Aufgabe und plant neu ein.
|
||||||
@@ -50,7 +50,8 @@ test.describe('Anmeldung', () => {
|
|||||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
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 page.getByTitle('Abmelden').click()
|
||||||
await expect(page).toHaveURL(/\/anmelden/)
|
await expect(page).toHaveURL(/\/anmelden/)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
|
||||||
|
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
|
||||||
|
// gelegentlich schneller, als gespeichert wird.
|
||||||
|
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wochenansicht und Zeitblöcke.
|
||||||
|
*
|
||||||
|
* Ohne echtes Kalenderkonto lässt sich der Schreibweg zum Anbieter nicht
|
||||||
|
* prüfen — er scheitert dann und der Zeitblock trägt einen Fehler. Genau das
|
||||||
|
* ist aber selbst prüfenswert: Die Planung muss auch dann überleben, wenn der
|
||||||
|
* Kalender nicht erreichbar ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
||||||
|
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||||
|
const PRAEFIX = `E2E-W-${Date.now()}`
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL ?? ''
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||||
|
|
||||||
|
test.describe('Woche', () => {
|
||||||
|
test.skip(
|
||||||
|
!EMAIL || !PASSWORT || !verbindung,
|
||||||
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
|
)
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.zeitblock.deleteMany({
|
||||||
|
where: { aufgabe: { titel: { startsWith: PRAEFIX } } },
|
||||||
|
})
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'zeitblock.' } } })
|
||||||
|
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('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
||||||
|
await db.kalenderkonto.deleteMany({})
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Woche' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Noch kein Kalender verbunden')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText('Ohne verbundenen Kalender lässt sich nichts'),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt einen Zeitblock im Raster und plant ihn wieder aus', async ({ page }) => {
|
||||||
|
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||||
|
|
||||||
|
// Ein Kalenderkonto ohne gültige Tokens: die Ansicht funktioniert, der
|
||||||
|
// Schreibweg zum Anbieter nicht.
|
||||||
|
const konto = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: `${PRAEFIX} Testkalender`,
|
||||||
|
extKontoId: `${PRAEFIX}-konto`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const aufgabe = await db.aufgabe.create({
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, aufwandMinuten: 60 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zeitblock auf 10:00 Uhr des Montags dieser Woche legen.
|
||||||
|
const jetzt = new Date()
|
||||||
|
const montag = new Date(jetzt)
|
||||||
|
montag.setUTCHours(0, 0, 0, 0)
|
||||||
|
montag.setUTCDate(montag.getUTCDate() - ((montag.getUTCDay() + 6) % 7))
|
||||||
|
const beginn = new Date(montag.getTime() + 10 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
beginn,
|
||||||
|
ende: new Date(beginn.getTime() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: aufgabe.id },
|
||||||
|
data: { status: 'GEPLANT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
const block = page.getByTitle(new RegExp(`${PRAEFIX} Angebot schreiben`))
|
||||||
|
await expect(block).toBeVisible()
|
||||||
|
|
||||||
|
// Ausplanen: der Block verschwindet, die Aufgabe fällt zurück auf offen.
|
||||||
|
await block.getByRole('button', { name: 'Ausplanen' }).click()
|
||||||
|
await expect(page.getByText('ausgeplant')).toBeVisible()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => db.zeitblock.count({ where: { aufgabeId: aufgabe.id } }))
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
const danach = await db.aufgabe.findUniqueOrThrow({ where: { id: aufgabe.id } })
|
||||||
|
expect(danach.status).toBe('OFFEN')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt sich durch die Wochen blättern', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Weiter' }).click()
|
||||||
|
await expect(page).toHaveURL(/versatz=1/)
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Zurück' }).click()
|
||||||
|
await expect(page).toHaveURL(/versatz=0/)
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Diese Woche' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/woche$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "2.11.3",
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"@radix-ui/react-avatar": "1.2.6",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
@@ -593,6 +595,45 @@
|
|||||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@electric-sql/pglite": {
|
"node_modules/@electric-sql/pglite": {
|
||||||
"version": "0.4.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "2.11.3",
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"@radix-ui/react-avatar": "1.2.6",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
|
|||||||
@@ -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> {
|
async function main(): Promise<void> {
|
||||||
console.log('Demo-Daten einspielen.')
|
console.log('Demo-Daten einspielen.')
|
||||||
|
|
||||||
@@ -158,6 +270,154 @@ 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kalenderkonto und erfundene Termine ---------------------------------
|
||||||
|
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
|
||||||
|
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
|
||||||
|
await db.kalenderkonto.deleteMany({})
|
||||||
|
const kalenderkonto = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: 'Firma Outlook',
|
||||||
|
extKontoId: 'martina.beispiel@firma.invalid',
|
||||||
|
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TERMINE = [
|
||||||
|
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
|
||||||
|
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
|
||||||
|
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
|
||||||
|
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const eintrag of TERMINE) {
|
||||||
|
await db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||||
|
betreff: eintrag.betreff,
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
|
||||||
|
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: 'demo-brueckentag',
|
||||||
|
betreff: 'Brückentag',
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
|
||||||
|
ganztaegig: true,
|
||||||
|
zeigtAls: 'ABWESEND',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||||||
|
await db.zeitblock.deleteMany({})
|
||||||
|
const einzuplanen = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
titel: {
|
||||||
|
in: [
|
||||||
|
'Wochenbericht an die Geschäftsführung',
|
||||||
|
'Angebot Heizungstausch nachfassen',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true, aufwandMinuten: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let blockStunde = 15
|
||||||
|
for (const aufgabe of einzuplanen) {
|
||||||
|
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||||||
|
await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
beginn,
|
||||||
|
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||||||
|
extId: `demo-block-${aufgabe.id}`,
|
||||||
|
hash: 'demo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||||
|
blockStunde += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`)
|
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;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kalenderkonto" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"anbieter" "KalenderAnbieter" NOT NULL,
|
||||||
|
"anzeigename" TEXT NOT NULL,
|
||||||
|
"extKontoId" TEXT NOT NULL,
|
||||||
|
"kalenderId" TEXT,
|
||||||
|
"zugriffstoken" TEXT,
|
||||||
|
"aktualisierungstoken" TEXT,
|
||||||
|
"tokenGueltigBis" TIMESTAMP(3),
|
||||||
|
"bereiche" TEXT,
|
||||||
|
"deltaVerweis" TEXT,
|
||||||
|
"letzterAbgleich" TIMESTAMP(3),
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "termin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kalenderkontoId" TEXT NOT NULL,
|
||||||
|
"extId" TEXT NOT NULL,
|
||||||
|
"etag" TEXT,
|
||||||
|
"betreff" TEXT,
|
||||||
|
"beginn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
|
||||||
|
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "zeitblock" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"aufgabeId" TEXT NOT NULL,
|
||||||
|
"kalenderkontoId" TEXT NOT NULL,
|
||||||
|
"beginn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMP(3) NOT NULL,
|
||||||
|
"extId" TEXT,
|
||||||
|
"etag" TEXT,
|
||||||
|
"hash" TEXT,
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
// Fachbegriffe auf Deutsch, technische Felder auf Englisch.
|
// Fachbegriffe auf Deutsch, technische Felder auf Englisch.
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
moduleFormat = "esm"
|
moduleFormat = "esm"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,22 +18,23 @@ enum Rolle {
|
|||||||
|
|
||||||
/// Ein Mensch, der sich anmelden darf.
|
/// Ein Mensch, der sich anmelden darf.
|
||||||
model Benutzer {
|
model Benutzer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
|
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
|
||||||
passwortHash String?
|
passwortHash String?
|
||||||
rolle Rolle @default(BENUTZER)
|
rolle Rolle @default(BENUTZER)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
bildUrl String?
|
bildUrl String?
|
||||||
emailGeprueft DateTime?
|
emailGeprueft DateTime?
|
||||||
zuletztAktiv DateTime?
|
zuletztAktiv DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
konten Konto[]
|
konten Konto[]
|
||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
audit AuditEintrag[]
|
audit AuditEintrag[]
|
||||||
|
kalenderkonten Kalenderkonto[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -63,19 +64,19 @@ model Konto {
|
|||||||
|
|
||||||
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
|
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
|
||||||
model Passkey {
|
model Passkey {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
benutzerId String
|
benutzerId String
|
||||||
/// Base64URL der Credential-ID.
|
/// Base64URL der Credential-ID.
|
||||||
credentialId String @unique
|
credentialId String @unique
|
||||||
publicKey Bytes
|
publicKey Bytes
|
||||||
counter BigInt @default(0)
|
counter BigInt @default(0)
|
||||||
/// Kommagetrennt, z. B. "internal,hybrid".
|
/// Kommagetrennt, z. B. "internal,hybrid".
|
||||||
transports String?
|
transports String?
|
||||||
geraetetyp String?
|
geraetetyp String?
|
||||||
gesichert Boolean @default(false)
|
gesichert Boolean @default(false)
|
||||||
bezeichnung String
|
bezeichnung String
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
zuletztBenutzt DateTime?
|
zuletztBenutzt DateTime?
|
||||||
|
|
||||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -98,12 +99,12 @@ model WebauthnChallenge {
|
|||||||
|
|
||||||
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
|
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
|
||||||
model Bereich {
|
model Bereich {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
|
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
|
||||||
farbe String
|
farbe String
|
||||||
reihenfolge Int @default(0)
|
reihenfolge Int @default(0)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
|
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
|
||||||
kapazitaetMo Int @default(0)
|
kapazitaetMo Int @default(0)
|
||||||
@@ -121,23 +122,134 @@ model Bereich {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
|
||||||
|
standardKalenderkontoId String?
|
||||||
|
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
aufgaben Aufgabe[]
|
||||||
|
projekte Projekt[]
|
||||||
|
|
||||||
@@index([aktiv, reihenfolge])
|
@@index([aktiv, reihenfolge])
|
||||||
@@map("bereich")
|
@@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)
|
||||||
|
zeitbloecke Zeitblock[]
|
||||||
|
|
||||||
|
/// 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.
|
/// Unveraenderlicher Nachweis jeder schreibenden Aktion.
|
||||||
model AuditEintrag {
|
model AuditEintrag {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
zeitpunkt DateTime @default(now())
|
zeitpunkt DateTime @default(now())
|
||||||
benutzerId String?
|
benutzerId String?
|
||||||
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
|
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
|
||||||
benutzerEmail String
|
benutzerEmail String
|
||||||
aktion String
|
aktion String
|
||||||
objektTyp String
|
objektTyp String
|
||||||
objektId String?
|
objektId String?
|
||||||
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
|
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
|
||||||
details Json?
|
details Json?
|
||||||
instanz String
|
instanz String
|
||||||
|
|
||||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@ -146,3 +258,123 @@ model AuditEintrag {
|
|||||||
@@index([benutzerId])
|
@@index([benutzerId])
|
||||||
@@map("audit_eintrag")
|
@@map("audit_eintrag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum KalenderAnbieter {
|
||||||
|
GRAPH
|
||||||
|
GOOGLE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
|
||||||
|
enum ZeigtAls {
|
||||||
|
FREI
|
||||||
|
GEBUCHT
|
||||||
|
MIT_VORBEHALT
|
||||||
|
ABWESEND
|
||||||
|
WOANDERS
|
||||||
|
UNBEKANNT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
|
||||||
|
model Kalenderkonto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
anbieter KalenderAnbieter
|
||||||
|
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
|
||||||
|
anzeigename String
|
||||||
|
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
|
||||||
|
extKontoId String
|
||||||
|
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
|
||||||
|
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
|
||||||
|
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
|
||||||
|
kalenderId String @default("standard")
|
||||||
|
|
||||||
|
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
|
||||||
|
zugriffstoken String?
|
||||||
|
aktualisierungstoken String?
|
||||||
|
tokenGueltigBis DateTime?
|
||||||
|
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
|
||||||
|
bereiche String?
|
||||||
|
|
||||||
|
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
|
||||||
|
deltaVerweis String?
|
||||||
|
letzterAbgleich DateTime?
|
||||||
|
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||||
|
letzterFehler String?
|
||||||
|
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
termine Termin[]
|
||||||
|
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
||||||
|
zeitblocks Zeitblock[]
|
||||||
|
|
||||||
|
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
|
||||||
|
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
|
||||||
|
@@index([benutzerId, aktiv])
|
||||||
|
@@map("kalenderkonto")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
|
||||||
|
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
|
||||||
|
model Termin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kalenderkontoId String
|
||||||
|
/// Kennung des Termins beim Anbieter.
|
||||||
|
extId String
|
||||||
|
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
|
||||||
|
etag String?
|
||||||
|
|
||||||
|
betreff String?
|
||||||
|
beginn DateTime
|
||||||
|
ende DateTime
|
||||||
|
ganztaegig Boolean @default(false)
|
||||||
|
zeigtAls ZeigtAls @default(GEBUCHT)
|
||||||
|
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([kalenderkontoId, extId])
|
||||||
|
@@index([kalenderkontoId, beginn])
|
||||||
|
@@index([beginn, ende])
|
||||||
|
@@map("termin")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
|
||||||
|
///
|
||||||
|
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||||
|
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
|
||||||
|
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
|
||||||
|
model Zeitblock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
aufgabeId String
|
||||||
|
kalenderkontoId String
|
||||||
|
|
||||||
|
beginn DateTime
|
||||||
|
ende DateTime
|
||||||
|
|
||||||
|
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
|
||||||
|
extId String?
|
||||||
|
/// Fuer bedingte Schreibzugriffe (If-Match).
|
||||||
|
etag String?
|
||||||
|
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
|
||||||
|
hash String?
|
||||||
|
|
||||||
|
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
|
||||||
|
letzterFehler String?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
|
||||||
|
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
|
||||||
|
@@unique([kalenderkontoId, extId])
|
||||||
|
@@index([beginn, ende])
|
||||||
|
@@index([aufgabeId])
|
||||||
|
@@map("zeitblock")
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ type Aufnahme = {
|
|||||||
const AUFNAHMEN: Aufnahme[] = [
|
const AUFNAHMEN: Aufnahme[] = [
|
||||||
{ name: 'anmelden', pfad: '/anmelden' },
|
{ name: 'anmelden', pfad: '/anmelden' },
|
||||||
{ name: 'start', pfad: '/' },
|
{ name: 'start', pfad: '/' },
|
||||||
|
{ name: 'inbox', pfad: '/inbox' },
|
||||||
|
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||||
|
{ name: 'woche', pfad: '/woche' },
|
||||||
|
{ name: 'projekte', pfad: '/projekte' },
|
||||||
|
{ name: 'ideen', pfad: '/ideen' },
|
||||||
{ name: 'bereiche', pfad: '/bereiche' },
|
{ name: 'bereiche', pfad: '/bereiche' },
|
||||||
|
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -44,9 +44,15 @@ const kennung = `zzz-test-${Date.now()}`
|
|||||||
const testEmail = `${kennung}@example.invalid`
|
const testEmail = `${kennung}@example.invalid`
|
||||||
|
|
||||||
async function aufraeumen(): Promise<void> {
|
async function aufraeumen(): Promise<void> {
|
||||||
// Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag
|
// Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise
|
||||||
// hängt bewusst an keinem Benutzer mehr.
|
// 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.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||||
|
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
|
||||||
|
// vorher abbricht, hier trotzdem noch einmal aufräumen.
|
||||||
|
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
|
||||||
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||||
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||||
@@ -143,6 +149,102 @@ async function main(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Kalenderkonto und Termine ------------------------------------------
|
||||||
|
const kalenderkonto = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: `${kennung} Firma Outlook`,
|
||||||
|
extKontoId: `${kennung}-konto`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe(
|
||||||
|
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
|
||||||
|
kalenderkonto.kalenderId === 'standard',
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Ein frisches Konto hat noch keinen Abgleichverweis',
|
||||||
|
kalenderkonto.deltaVerweis === null,
|
||||||
|
)
|
||||||
|
|
||||||
|
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
|
||||||
|
db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: `${kennung} Doppelt`,
|
||||||
|
extKontoId: `${kennung}-konto`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
|
||||||
|
const zweiterKalender = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: `${kennung} Zweiter Kalender`,
|
||||||
|
extKontoId: `${kennung}-konto`,
|
||||||
|
kalenderId: 'AAMkAD-zweiter',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe(
|
||||||
|
'Dasselbe Konto mit anderem Kalender ist erlaubt',
|
||||||
|
zweiterKalender.id !== kalenderkonto.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const termin = await db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `${kennung}-termin`,
|
||||||
|
betreff: 'Wochenbesprechung',
|
||||||
|
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T09:00:00Z'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
|
||||||
|
|
||||||
|
await scheitert('Derselbe Termin je Konto nur einmal', () =>
|
||||||
|
db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `${kennung}-termin`,
|
||||||
|
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T09:00:00Z'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// So arbeitet der Abgleich: upsert statt anlegen.
|
||||||
|
const gewandelterTermin = await db.termin.upsert({
|
||||||
|
where: {
|
||||||
|
kalenderkontoId_extId: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `${kennung}-termin`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
|
||||||
|
create: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `${kennung}-termin`,
|
||||||
|
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T09:00:00Z'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe(
|
||||||
|
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
|
||||||
|
gewandelterTermin.id === termin.id &&
|
||||||
|
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
|
||||||
|
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
|
||||||
|
await db.bereich.update({
|
||||||
|
where: { id: bereich.id },
|
||||||
|
data: { standardKalenderkontoId: kalenderkonto.id },
|
||||||
|
})
|
||||||
|
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
|
||||||
|
|
||||||
// --- Audit überlebt den Benutzer ----------------------------------------
|
// --- Audit überlebt den Benutzer ----------------------------------------
|
||||||
const audit = await db.auditEintrag.create({
|
const audit = await db.auditEintrag.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -174,6 +276,133 @@ async function main(): Promise<void> {
|
|||||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||||
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
||||||
)
|
)
|
||||||
|
pruefe(
|
||||||
|
'Kalenderkonten verschwinden mit dem Benutzer',
|
||||||
|
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
|
||||||
|
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos',
|
||||||
|
(await db.bereich.findUnique({ where: { id: bereich.id } }))
|
||||||
|
?.standardKalenderkontoId === null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 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
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Loader2, RefreshCw, Unplug } 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 { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
jetztAbgleichen,
|
||||||
|
kontoTrennen,
|
||||||
|
kontoUmbenennen,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/kalenderkonten'
|
||||||
|
|
||||||
|
export type KontoAnzeige = {
|
||||||
|
id: string
|
||||||
|
anbieter: 'GRAPH' | 'GOOGLE'
|
||||||
|
anbieterName: string
|
||||||
|
anzeigename: string
|
||||||
|
extKontoId: string
|
||||||
|
termine: number
|
||||||
|
letzterAbgleich: string | null
|
||||||
|
letzterFehler: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KontoKarte({ konto }: { konto: KontoAnzeige }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [name, setName] = useState(konto.anzeigename)
|
||||||
|
const [trennenBestaetigt, setTrennenBestaetigt] = useState(false)
|
||||||
|
|
||||||
|
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 felder(werte: Record<string, string>): FormData {
|
||||||
|
const daten = new FormData()
|
||||||
|
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
|
||||||
|
return daten
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardKopf className="flex-row flex-wrap items-center gap-2">
|
||||||
|
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
|
||||||
|
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
|
||||||
|
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="flex flex-col gap-4">
|
||||||
|
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Konto</dt>
|
||||||
|
<dd>{konto.extKontoId}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
|
||||||
|
<dd>{konto.termine}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
|
||||||
|
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{konto.letzterFehler && (
|
||||||
|
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
|
||||||
|
{konto.letzterFehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor={`name-${konto.id}`}
|
||||||
|
className="text-gedaempft-vordergrund text-xs"
|
||||||
|
>
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={`name-${konto.id}`}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
maxLength={60}
|
||||||
|
disabled={laeuft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft || name.trim() === konto.anzeigename}
|
||||||
|
onClick={() =>
|
||||||
|
mitRueckmeldung(() =>
|
||||||
|
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
)}
|
||||||
|
Jetzt abgleichen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="gefahr"
|
||||||
|
className="ml-auto"
|
||||||
|
disabled={laeuft}
|
||||||
|
onBlur={() => setTrennenBestaetigt(false)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!trennenBestaetigt) {
|
||||||
|
setTrennenBestaetigt(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Unplug aria-hidden />
|
||||||
|
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trennenBestaetigt && (
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
|
||||||
|
unangetastet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort
|
||||||
|
* hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem
|
||||||
|
* Neuladen erneut.
|
||||||
|
*/
|
||||||
|
export function Meldung({
|
||||||
|
ok,
|
||||||
|
fehler,
|
||||||
|
}: {
|
||||||
|
ok?: string | undefined
|
||||||
|
fehler?: string | undefined
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const gezeigt = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gezeigt.current) return
|
||||||
|
if (!ok && !fehler) return
|
||||||
|
|
||||||
|
gezeigt.current = true
|
||||||
|
if (ok) toast.success(ok)
|
||||||
|
if (fehler) toast.error(fehler)
|
||||||
|
router.replace('/kalenderkonten')
|
||||||
|
}, [ok, fehler, router])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { CalendarDays, Plus } from 'lucide-react'
|
||||||
|
import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte'
|
||||||
|
import { Meldung } from '@/app/(app)/kalenderkonten/meldung'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { erlaubteKalenderAnbieter } from '@/server/instanz'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Kalenderkonten' }
|
||||||
|
|
||||||
|
const ANBIETER_NAME: Record<KalenderAnbieter, string> = {
|
||||||
|
GRAPH: 'Microsoft 365',
|
||||||
|
GOOGLE: 'Google',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function KalenderkontenSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ ok?: string; fehler?: string }>
|
||||||
|
}) {
|
||||||
|
const benutzer = await verlangeAnmeldung('/kalenderkonten')
|
||||||
|
const parameter = await searchParams
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
// Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie
|
||||||
|
// benutzbar sind, hängt zusätzlich an der Konfiguration.
|
||||||
|
const erlaubt = erlaubteKalenderAnbieter()
|
||||||
|
const verfuegbar = erlaubt.filter((anbieter) =>
|
||||||
|
anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv,
|
||||||
|
)
|
||||||
|
const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a))
|
||||||
|
|
||||||
|
const konten = await db.kalenderkonto.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
anbieter: true,
|
||||||
|
anzeigename: true,
|
||||||
|
extKontoId: true,
|
||||||
|
letzterAbgleich: true,
|
||||||
|
letzterFehler: true,
|
||||||
|
_count: { select: { termine: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||||
|
<CalendarDays className="size-6" aria-hidden />
|
||||||
|
Kalenderkonten
|
||||||
|
</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
|
||||||
|
schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Kalender verbinden</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
|
||||||
|
Der Planer speichert keine Zugangsdaten im Klartext.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt className="flex flex-col gap-3">
|
||||||
|
{verfuegbar.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{verfuegbar.map((anbieter) => (
|
||||||
|
<Button key={anbieter} alsKind>
|
||||||
|
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
{ANBIETER_NAME[anbieter]} verbinden
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fehlendKonfiguriert.length > 0 && (
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
|
||||||
|
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
|
||||||
|
Umgebungsvariablen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-medium">Verbundene Konten</h2>
|
||||||
|
|
||||||
|
{konten.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||||
|
Noch kein Kalender verbunden.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
konten.map((konto) => (
|
||||||
|
<KontoKarte
|
||||||
|
key={konto.id}
|
||||||
|
konto={{
|
||||||
|
id: konto.id,
|
||||||
|
anbieter: konto.anbieter,
|
||||||
|
anbieterName: ANBIETER_NAME[konto.anbieter],
|
||||||
|
anzeigename: konto.anzeigename,
|
||||||
|
extKontoId: konto.extKontoId,
|
||||||
|
termine: konto._count.termine,
|
||||||
|
letzterAbgleich: konto.letzterAbgleich
|
||||||
|
? alsDatumUhrzeit(konto.letzterAbgleich)
|
||||||
|
: null,
|
||||||
|
letzterFehler: konto.letzterFehler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Kopfleiste } from '@/components/kopfleiste'
|
import { Kopfleiste } from '@/components/kopfleiste'
|
||||||
|
import { zaehlstaende } from '@/server/abfragen/aufgaben'
|
||||||
import { INSTANZ_NAME } from '@/lib/instanz'
|
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||||
import { versionAnzeige } from '@/lib/version'
|
import { versionAnzeige } from '@/lib/version'
|
||||||
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
|
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
@@ -18,6 +19,7 @@ export default async function AngemeldetesLayout({
|
|||||||
// Anmeldung dort weitergeht, wo man hinwollte.
|
// Anmeldung dort weitergeht, wo man hinwollte.
|
||||||
const benutzer = await verlangeAnmeldung(await angefragterPfad())
|
const benutzer = await verlangeAnmeldung(await angefragterPfad())
|
||||||
const instanz = aktuelleInstanz()
|
const instanz = aktuelleInstanz()
|
||||||
|
const zahlen = await zaehlstaende()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({
|
|||||||
email={benutzer.email}
|
email={benutzer.email}
|
||||||
rolle={benutzer.rolle}
|
rolle={benutzer.rolle}
|
||||||
instanzName={INSTANZ_NAME[instanz]}
|
instanzName={INSTANZ_NAME[instanz]}
|
||||||
|
inbox={zahlen.inbox}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||||
|
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||||
|
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardBeschreibung,
|
CardBeschreibung,
|
||||||
@@ -8,6 +11,13 @@ import {
|
|||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { wochenKapazitaet } from '@/lib/bereich'
|
import { wochenKapazitaet } from '@/lib/bereich'
|
||||||
import { alsDatum, alsDauer } from '@/lib/zeit'
|
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||||
|
import {
|
||||||
|
aufgabenLaden,
|
||||||
|
bereicheLaden,
|
||||||
|
projekteZurAuswahlLaden,
|
||||||
|
zaehlstaende,
|
||||||
|
} from '@/server/abfragen/aufgaben'
|
||||||
|
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
@@ -16,10 +26,24 @@ export default async function StartSeite() {
|
|||||||
const benutzer = await verlangeAnmeldung('/')
|
const benutzer = await verlangeAnmeldung('/')
|
||||||
const heute = jetzt()
|
const heute = jetzt()
|
||||||
|
|
||||||
const bereiche = await db.bereich.findMany({
|
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||||
where: { aktiv: true },
|
await Promise.all([
|
||||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||||
})
|
bereicheLaden(),
|
||||||
|
projekteZurAuswahlLaden(),
|
||||||
|
zaehlstaende(),
|
||||||
|
db.bereich.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
belegungHeute(benutzer.id, heute),
|
||||||
|
hatKalenderkonto(benutzer.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 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
|
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
|
||||||
|
|
||||||
@@ -30,10 +54,53 @@ export default async function StartSeite() {
|
|||||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<Card>
|
||||||
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
<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>
|
<Card>
|
||||||
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||||
Noch keine Bereiche angelegt.{' '}
|
Noch keine Bereiche angelegt.{' '}
|
||||||
@@ -44,38 +111,70 @@ export default async function StartSeite() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-4 sm:grid-cols-2">
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
{bereiche.map((bereich) => (
|
{bereichsListe.map((bereich) => {
|
||||||
<li key={bereich.id}>
|
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||||
<Card>
|
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
|
||||||
<CardKopf className="flex-row items-center gap-3">
|
return (
|
||||||
<span
|
<li key={bereich.id}>
|
||||||
aria-hidden
|
<Card className="h-full">
|
||||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
style={{ backgroundColor: bereich.farbe }}
|
<span
|
||||||
/>
|
aria-hidden
|
||||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
</CardKopf>
|
style={{ backgroundColor: bereich.farbe }}
|
||||||
<CardInhalt>
|
/>
|
||||||
<p className="text-gedaempft-vordergrund text-sm">
|
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||||
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
</CardKopf>
|
||||||
</p>
|
<CardInhalt className="flex flex-col gap-2">
|
||||||
</CardInhalt>
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
</Card>
|
{offeneHier.length === 0
|
||||||
</li>
|
? 'Nichts offen'
|
||||||
))}
|
: `${offeneHier.length} offen`}{' '}
|
||||||
|
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Ohne verbundenen Kalender wäre die Belegung immer null –
|
||||||
|
das sähe nach "alles frei" aus und wäre irreführend.
|
||||||
|
*/}
|
||||||
|
{mitKalender && heuteHier && (
|
||||||
|
<>
|
||||||
|
<Fortschrittsbalken
|
||||||
|
prozent={heuteHier.prozent}
|
||||||
|
beschriftung={`Belegung heute in ${bereich.name}`}
|
||||||
|
/>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
{heuteHier.kapazitaet === 0
|
||||||
|
? 'Heute nicht eingeplant'
|
||||||
|
: heuteHier.ueberbucht
|
||||||
|
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
|
||||||
|
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card>
|
{zahlen.ideen > 0 && (
|
||||||
<CardKopf>
|
<Card>
|
||||||
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
<CardKopf>
|
||||||
<CardBeschreibung>
|
<CardTitel className="text-base">
|
||||||
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
|
||||||
Wochenplanung per Ziehen und Ablegen mit M4.
|
{zahlen.ideen} Idee(n) warten
|
||||||
</CardBeschreibung>
|
</Link>
|
||||||
</CardKopf>
|
</CardTitel>
|
||||||
</Card>
|
<CardBeschreibung>
|
||||||
|
Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</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,213 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { CalendarRange } from 'lucide-react'
|
||||||
|
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
|
||||||
|
import { Card, CardInhalt } from '@/components/ui/card'
|
||||||
|
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
|
||||||
|
import {
|
||||||
|
belegteMinuten,
|
||||||
|
arbeitsfensterAmTag,
|
||||||
|
kapazitaetAmTag,
|
||||||
|
} from '@/lib/kalender/belegung'
|
||||||
|
import { fensterHuelle, rasterBauen } from '@/lib/woche'
|
||||||
|
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Woche' }
|
||||||
|
|
||||||
|
const WOCHENTAGE = [
|
||||||
|
'Montag',
|
||||||
|
'Dienstag',
|
||||||
|
'Mittwoch',
|
||||||
|
'Donnerstag',
|
||||||
|
'Freitag',
|
||||||
|
'Samstag',
|
||||||
|
'Sonntag',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function WocheSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ versatz?: string }>
|
||||||
|
}) {
|
||||||
|
const benutzer = await verlangeAnmeldung('/woche')
|
||||||
|
const parameter = await searchParams
|
||||||
|
|
||||||
|
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
|
||||||
|
// bestimmte Woche als Lesezeichen taugt.
|
||||||
|
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
|
||||||
|
const sichereWochen = Number.isFinite(versatz)
|
||||||
|
? Math.max(-52, Math.min(52, versatz))
|
||||||
|
: 0
|
||||||
|
const heute = jetzt()
|
||||||
|
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
|
||||||
|
|
||||||
|
const [daten, aufgaben, bereiche, konto] = await Promise.all([
|
||||||
|
wocheLaden(benutzer.id, zeitpunkt),
|
||||||
|
planbareAufgabenLaden(),
|
||||||
|
db.bereich.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
|
||||||
|
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
|
||||||
|
db.kalenderkonto.findFirst({
|
||||||
|
where: { benutzerId: benutzer.id, aktiv: true },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: { id: true, anzeigename: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const huelle = fensterHuelle(bereiche)
|
||||||
|
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
|
||||||
|
|
||||||
|
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
|
||||||
|
// Termin blockiert die Zeit für alles gleichermaßen.
|
||||||
|
const spalten = raster.spalten.map((spalte) => {
|
||||||
|
const tag = spalte.tagesBeginn
|
||||||
|
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
|
||||||
|
|
||||||
|
const belegend = [
|
||||||
|
...daten.termine.map((t) => ({
|
||||||
|
beginn: t.beginn,
|
||||||
|
ende: t.ende,
|
||||||
|
ganztaegig: t.ganztaegig,
|
||||||
|
zeigtAls: t.zeigtAls,
|
||||||
|
})),
|
||||||
|
...daten.zeitbloecke.map((z) => ({
|
||||||
|
beginn: z.beginn,
|
||||||
|
ende: z.ende,
|
||||||
|
ganztaegig: false,
|
||||||
|
zeigtAls: 'GEBUCHT' as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
|
||||||
|
// die Tageskapazität ist hier die Summe über alle Bereiche.
|
||||||
|
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
|
||||||
|
const belegt = belegteMinuten(belegend, fenster)
|
||||||
|
|
||||||
|
return {
|
||||||
|
schluessel: spalte.schluessel,
|
||||||
|
tagesBeginnMs: spalte.tagesBeginn.getTime(),
|
||||||
|
name: WOCHENTAGE[spalte.wochentag] ?? '',
|
||||||
|
datum: alsDatum(tag).split(', ')[1] ?? '',
|
||||||
|
heute: gleicherTag(tag, heute),
|
||||||
|
kapazitaet,
|
||||||
|
belegt,
|
||||||
|
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
|
||||||
|
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
|
||||||
|
|
||||||
|
return (
|
||||||
|
/*
|
||||||
|
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
|
||||||
|
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
|
||||||
|
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
|
||||||
|
100rem, mittig und mit demselben Seitenabstand wie überall.
|
||||||
|
*/
|
||||||
|
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||||
|
<CalendarRange className="size-6" aria-hidden />
|
||||||
|
Woche
|
||||||
|
</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
{alsDatum(montag)} bis {alsDatum(sonntag)}
|
||||||
|
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
|
||||||
|
<Link
|
||||||
|
href={`/woche?versatz=${sichereWochen - 1}`}
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/woche"
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Diese Woche
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/woche?versatz=${sichereWochen + 1}`}
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!konto && (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="py-4 text-sm">
|
||||||
|
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
|
||||||
|
<Link
|
||||||
|
href="/kalenderkonten"
|
||||||
|
className="text-betont underline underline-offset-4"
|
||||||
|
>
|
||||||
|
jetzt verbinden
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Wochenraster
|
||||||
|
spalten={spalten}
|
||||||
|
zeilen={raster.zeilen}
|
||||||
|
vonMinute={raster.vonMinute}
|
||||||
|
bisMinute={raster.bisMinute}
|
||||||
|
kalenderkontoId={konto?.id ?? null}
|
||||||
|
zeitbloecke={daten.zeitbloecke.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
beginnMs: z.beginn.getTime(),
|
||||||
|
endeMs: z.ende.getTime(),
|
||||||
|
titel: z.titel,
|
||||||
|
bereichFarbe: z.bereichFarbe,
|
||||||
|
kalenderName: z.kalenderName,
|
||||||
|
letzterFehler: z.letzterFehler,
|
||||||
|
offen: z.offen,
|
||||||
|
}))}
|
||||||
|
termine={daten.termine.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
beginnMs: t.beginn.getTime(),
|
||||||
|
endeMs: t.ende.getTime(),
|
||||||
|
betreff: t.betreff,
|
||||||
|
ganztaegig: t.ganztaegig,
|
||||||
|
frei: t.zeigtAls === 'FREI',
|
||||||
|
kalenderName: t.kalenderName,
|
||||||
|
}))}
|
||||||
|
aufgaben={aufgaben.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
titel: a.titel,
|
||||||
|
aufwandMinuten: a.aufwandMinuten,
|
||||||
|
bereichName: a.bereich?.name ?? null,
|
||||||
|
bereichFarbe: a.bereich?.farbe ?? null,
|
||||||
|
faelligText: a.faelligAm
|
||||||
|
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
|
||||||
|
: null,
|
||||||
|
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
|
||||||
|
bereitsGeplant: a._count.zeitbloecke,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
||||||
|
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
|
||||||
|
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
ZEILE_HOEHE,
|
||||||
|
balkenlage,
|
||||||
|
spurenVerteilen,
|
||||||
|
zeilenBeschriftung,
|
||||||
|
zeitpunktAus,
|
||||||
|
type Raster,
|
||||||
|
} from '@/lib/woche'
|
||||||
|
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
zeitblockAnlegen,
|
||||||
|
zeitblockLoesen,
|
||||||
|
zeitblockVerschieben,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/zeitbloecke'
|
||||||
|
|
||||||
|
export type PlanbareAufgabe = {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
aufwandMinuten: number | null
|
||||||
|
bereichName: string | null
|
||||||
|
bereichFarbe: string | null
|
||||||
|
faelligText: string | null
|
||||||
|
ueberfaellig: boolean
|
||||||
|
bereitsGeplant: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterZeitblock = {
|
||||||
|
id: string
|
||||||
|
beginnMs: number
|
||||||
|
endeMs: number
|
||||||
|
titel: string
|
||||||
|
bereichFarbe: string | null
|
||||||
|
kalenderName: string
|
||||||
|
letzterFehler: string | null
|
||||||
|
offen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterTermin = {
|
||||||
|
id: string
|
||||||
|
beginnMs: number
|
||||||
|
endeMs: number
|
||||||
|
betreff: string | null
|
||||||
|
ganztaegig: boolean
|
||||||
|
frei: boolean
|
||||||
|
kalenderName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterSpalte = {
|
||||||
|
schluessel: string
|
||||||
|
tagesBeginnMs: number
|
||||||
|
name: string
|
||||||
|
datum: string
|
||||||
|
heute: boolean
|
||||||
|
kapazitaet: number
|
||||||
|
belegt: number
|
||||||
|
ueberbucht: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
spalten: RasterSpalte[]
|
||||||
|
zeilen: number[]
|
||||||
|
vonMinute: number
|
||||||
|
bisMinute: number
|
||||||
|
zeitbloecke: RasterZeitblock[]
|
||||||
|
termine: RasterTermin[]
|
||||||
|
aufgaben: PlanbareAufgabe[]
|
||||||
|
kalenderkontoId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
|
||||||
|
const STANDARD_MINUTEN = 60
|
||||||
|
|
||||||
|
export function Wochenraster({
|
||||||
|
spalten,
|
||||||
|
zeilen,
|
||||||
|
vonMinute,
|
||||||
|
bisMinute,
|
||||||
|
zeitbloecke,
|
||||||
|
termine,
|
||||||
|
aufgaben,
|
||||||
|
kalenderkontoId,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [gezogen, setGezogen] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sensoren = useSensors(
|
||||||
|
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
|
||||||
|
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const raster: Raster = {
|
||||||
|
spalten: spalten.map((s) => ({
|
||||||
|
tagesBeginn: new Date(s.tagesBeginnMs),
|
||||||
|
wochentag: 0,
|
||||||
|
schluessel: s.schluessel,
|
||||||
|
})),
|
||||||
|
zeilen,
|
||||||
|
vonMinute,
|
||||||
|
bisMinute,
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) toast.success(antwort.meldung)
|
||||||
|
else toast.error(antwort.fehler)
|
||||||
|
router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function beimStart(ereignis: DragStartEvent) {
|
||||||
|
setGezogen(String(ereignis.active.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function beimEnde(ereignis: DragEndEvent) {
|
||||||
|
setGezogen(null)
|
||||||
|
const ziel = ereignis.over?.id
|
||||||
|
if (!ziel) return
|
||||||
|
|
||||||
|
const [schluessel, minuteRoh] = String(ziel).split('@')
|
||||||
|
if (!schluessel || minuteRoh === undefined) return
|
||||||
|
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
|
||||||
|
|
||||||
|
const kennung = String(ereignis.active.id)
|
||||||
|
|
||||||
|
if (kennung.startsWith('aufgabe:')) {
|
||||||
|
if (!kalenderkontoId) {
|
||||||
|
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const aufgabeId = kennung.slice('aufgabe:'.length)
|
||||||
|
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
|
||||||
|
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
|
||||||
|
const ende = new Date(beginn.getTime() + dauer * 60_000)
|
||||||
|
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('aufgabeId', aufgabeId)
|
||||||
|
daten.set('kalenderkontoId', kalenderkontoId)
|
||||||
|
daten.set('beginn', beginn.toISOString())
|
||||||
|
daten.set('ende', ende.toISOString())
|
||||||
|
mitRueckmeldung(() => zeitblockAnlegen(daten))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kennung.startsWith('block:')) {
|
||||||
|
const id = kennung.slice('block:'.length)
|
||||||
|
const block = zeitbloecke.find((z) => z.id === id)
|
||||||
|
if (!block) return
|
||||||
|
const dauer = block.endeMs - block.beginnMs
|
||||||
|
const ende = new Date(beginn.getTime() + dauer)
|
||||||
|
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
daten.set('beginn', beginn.toISOString())
|
||||||
|
daten.set('ende', ende.toISOString())
|
||||||
|
mitRueckmeldung(() => zeitblockVerschieben(daten))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausplanen(id: string) {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
mitRueckmeldung(() => zeitblockLoesen(daten))
|
||||||
|
}
|
||||||
|
|
||||||
|
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
|
||||||
|
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
|
||||||
|
: null
|
||||||
|
const gezogenerBlock = gezogen?.startsWith('block:')
|
||||||
|
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
|
{/* Vorrat: was noch eingeplant werden kann */}
|
||||||
|
<aside className="lg:w-72 lg:shrink-0">
|
||||||
|
<h2 className="mb-2 text-sm font-medium">
|
||||||
|
Einzuplanen
|
||||||
|
<span className="text-gedaempft-vordergrund ml-2 font-normal">
|
||||||
|
{aufgaben.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!kalenderkontoId && (
|
||||||
|
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
|
||||||
|
Ohne verbundenen Kalender lässt sich nichts einplanen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
|
||||||
|
{aufgaben.length === 0 ? (
|
||||||
|
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
|
||||||
|
Nichts offen. Ein guter Tag.
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
aufgaben.map((aufgabe) => (
|
||||||
|
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Das Wochenraster */}
|
||||||
|
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||||
|
<div className="min-w-[46rem]">
|
||||||
|
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||||
|
<div />
|
||||||
|
{spalten.map((spalte) => (
|
||||||
|
<div
|
||||||
|
key={spalte.schluessel}
|
||||||
|
className={cn(
|
||||||
|
'border-rand border-b px-1 pb-2 text-center',
|
||||||
|
spalte.heute && 'bg-betont/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{spalte.name}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-xs',
|
||||||
|
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{spalte.kapazitaet === 0
|
||||||
|
? '—'
|
||||||
|
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||||
|
{/* Stundenleiste */}
|
||||||
|
<div className="relative">
|
||||||
|
{zeilen.map((minute) => (
|
||||||
|
<div
|
||||||
|
key={minute}
|
||||||
|
style={{ height: ZEILE_HOEHE }}
|
||||||
|
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
|
||||||
|
>
|
||||||
|
{zeilenBeschriftung(minute)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spalten.map((spalte) => {
|
||||||
|
const blockeHier = zeitbloecke.filter(
|
||||||
|
(z) =>
|
||||||
|
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||||
|
z.endeMs > spalte.tagesBeginnMs,
|
||||||
|
)
|
||||||
|
const termineHier = termine.filter(
|
||||||
|
(t) =>
|
||||||
|
!t.ganztaegig &&
|
||||||
|
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||||
|
t.endeMs > spalte.tagesBeginnMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const verteilteTermine = spurenVerteilen(
|
||||||
|
termineHier.map((t) => ({
|
||||||
|
...t,
|
||||||
|
beginn: new Date(t.beginnMs),
|
||||||
|
ende: new Date(t.endeMs),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={spalte.schluessel}
|
||||||
|
className={cn(
|
||||||
|
'border-rand relative border-l',
|
||||||
|
spalte.heute && 'bg-betont/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zeilen.map((minute) => (
|
||||||
|
<Zielfeld
|
||||||
|
key={minute}
|
||||||
|
kennung={`${spalte.schluessel}@${minute}`}
|
||||||
|
volleStunde={minute % 60 === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
|
||||||
|
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
|
||||||
|
const lage = balkenlage(
|
||||||
|
eintrag.beginn,
|
||||||
|
eintrag.ende,
|
||||||
|
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
if (lage.ausserhalb) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={eintrag.id}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
|
||||||
|
eintrag.frei
|
||||||
|
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||||
|
: 'bg-gedaempft text-gedaempft-vordergrund',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: lage.oben,
|
||||||
|
height: lage.hoehe,
|
||||||
|
left: `${(spur / spuren) * 100}%`,
|
||||||
|
width: `${100 / spuren}%`,
|
||||||
|
}}
|
||||||
|
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{eintrag.betreff ?? 'Termin'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
|
||||||
|
{blockeHier.map((block) => {
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date(block.beginnMs),
|
||||||
|
new Date(block.endeMs),
|
||||||
|
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
if (lage.ausserhalb) return null
|
||||||
|
return (
|
||||||
|
<Zeitblockbalken
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
oben={lage.oben}
|
||||||
|
hoehe={lage.hoehe}
|
||||||
|
gesperrt={laeuft}
|
||||||
|
onAusplanen={() => ausplanen(block.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{gezogeneAufgabe && (
|
||||||
|
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||||
|
{gezogeneAufgabe.titel}
|
||||||
|
<span className="ml-1 opacity-80">
|
||||||
|
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gezogenerBlock && (
|
||||||
|
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||||
|
{gezogenerBlock.titel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: kennung })
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ height: ZEILE_HOEHE }}
|
||||||
|
className={cn(
|
||||||
|
'border-rand/40',
|
||||||
|
volleStunde ? 'border-t' : 'border-t border-dashed',
|
||||||
|
isOver && 'bg-betont/20',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AufgabenKachel({
|
||||||
|
aufgabe,
|
||||||
|
gesperrt,
|
||||||
|
}: {
|
||||||
|
aufgabe: PlanbareAufgabe
|
||||||
|
gesperrt: boolean
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `aufgabe:${aufgabe.id}`,
|
||||||
|
disabled: gesperrt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm leading-tight">{aufgabe.titel}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
{aufgabe.bereichName && (
|
||||||
|
<Kennzeichen>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
|
||||||
|
/>
|
||||||
|
{aufgabe.bereichName}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
<Kennzeichen>
|
||||||
|
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||||
|
</Kennzeichen>
|
||||||
|
{aufgabe.faelligText && (
|
||||||
|
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
|
||||||
|
{aufgabe.faelligText}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
{aufgabe.bereitsGeplant > 0 && (
|
||||||
|
<Kennzeichen art="betont">
|
||||||
|
<CalendarClock className="size-3" aria-hidden />
|
||||||
|
{aufgabe.bereitsGeplant}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zeitblockbalken({
|
||||||
|
block,
|
||||||
|
oben,
|
||||||
|
hoehe,
|
||||||
|
gesperrt,
|
||||||
|
onAusplanen,
|
||||||
|
}: {
|
||||||
|
block: RasterZeitblock
|
||||||
|
oben: number
|
||||||
|
hoehe: number
|
||||||
|
gesperrt: boolean
|
||||||
|
onAusplanen: () => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `block:${block.id}`,
|
||||||
|
disabled: gesperrt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const farbe = block.bereichFarbe ?? 'var(--betont)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
top: oben,
|
||||||
|
height: hoehe,
|
||||||
|
borderLeftColor: farbe,
|
||||||
|
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
|
||||||
|
isDragging && 'opacity-40',
|
||||||
|
)}
|
||||||
|
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
aria-label={`${block.titel} verschieben`}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-3 opacity-60" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
|
||||||
|
|
||||||
|
{(block.offen || block.letzterFehler) && (
|
||||||
|
<AlertTriangle
|
||||||
|
className="text-warnung size-3 shrink-0"
|
||||||
|
aria-label="Noch nicht im Kalender"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
className="size-4 shrink-0 p-0"
|
||||||
|
disabled={gesperrt}
|
||||||
|
title="Ausplanen"
|
||||||
|
onClick={onAusplanen}
|
||||||
|
>
|
||||||
|
<X className="size-3" aria-hidden />
|
||||||
|
<span className="sr-only">Ausplanen</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||||
|
import { kontoAbgleichen } from '@/server/kalender/abgleich'
|
||||||
|
import { codeEinloesen } from '@/server/kalender/oauth'
|
||||||
|
import {
|
||||||
|
alsAnbieter,
|
||||||
|
faehigkeitFuer,
|
||||||
|
istAnbieter,
|
||||||
|
zustandLesen,
|
||||||
|
} from '@/server/kalender/verbinden'
|
||||||
|
import { verschluesseln } from '@/server/krypto'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
|
||||||
|
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
|
||||||
|
ziel.searchParams.set(art, nachricht)
|
||||||
|
return NextResponse.redirect(ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
|
||||||
|
*
|
||||||
|
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
|
||||||
|
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ anbieter: string }> },
|
||||||
|
) {
|
||||||
|
const { anbieter: roh } = await params
|
||||||
|
if (!istAnbieter(roh)) notFound()
|
||||||
|
|
||||||
|
const anbieter = alsAnbieter(roh)
|
||||||
|
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||||
|
|
||||||
|
const adresse = new URL(anfrage.url)
|
||||||
|
const fehlerAnbieter = adresse.searchParams.get('error')
|
||||||
|
if (fehlerAnbieter) {
|
||||||
|
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
|
||||||
|
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const zustand = zustandLesen(adresse.searchParams.get('state'))
|
||||||
|
if (!zustand || zustand.anbieter !== anbieter) {
|
||||||
|
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = adresse.searchParams.get('code')
|
||||||
|
if (!code) {
|
||||||
|
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzer = await db.benutzer.findUnique({
|
||||||
|
where: { id: zustand.benutzerId },
|
||||||
|
select: { id: true, email: true, aktiv: true },
|
||||||
|
})
|
||||||
|
if (!benutzer?.aktiv) {
|
||||||
|
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const satz = await codeEinloesen(anbieter, code)
|
||||||
|
|
||||||
|
if (!satz.aktualisierungstoken) {
|
||||||
|
// Ohne das wäre die Verbindung nach einer Stunde tot.
|
||||||
|
return zurueck(
|
||||||
|
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
|
||||||
|
'fehler',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
|
||||||
|
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
|
||||||
|
|
||||||
|
const konto = await db.kalenderkonto.upsert({
|
||||||
|
where: {
|
||||||
|
benutzerId_anbieter_extKontoId_kalenderId: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter,
|
||||||
|
extKontoId,
|
||||||
|
kalenderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||||
|
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||||
|
tokenGueltigBis: satz.gueltigBis,
|
||||||
|
bereiche: satz.bereiche,
|
||||||
|
aktiv: true,
|
||||||
|
letzterFehler: null,
|
||||||
|
// Neu verbunden heißt: von vorn abgleichen.
|
||||||
|
deltaVerweis: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter,
|
||||||
|
anzeigename:
|
||||||
|
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
|
||||||
|
extKontoId,
|
||||||
|
kalenderId,
|
||||||
|
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||||
|
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||||
|
tokenGueltigBis: satz.gueltigBis,
|
||||||
|
bereiche: satz.bereiche,
|
||||||
|
},
|
||||||
|
select: { id: true, anzeigename: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'kalenderkonto.verbunden',
|
||||||
|
objektTyp: 'Kalenderkonto',
|
||||||
|
objektId: konto.id,
|
||||||
|
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
|
||||||
|
details: { anbieter, anzeigename: konto.anzeigename },
|
||||||
|
})
|
||||||
|
|
||||||
|
const bericht = await kontoAbgleichen(konto.id)
|
||||||
|
|
||||||
|
return zurueck(
|
||||||
|
bericht.ok
|
||||||
|
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
|
||||||
|
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
|
||||||
|
bericht.ok ? 'ok' : 'fehler',
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
|
||||||
|
return zurueck(meldung, 'fehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { autorisierungsAdresse } from '@/server/kalender/oauth'
|
||||||
|
import {
|
||||||
|
alsAnbieter,
|
||||||
|
faehigkeitFuer,
|
||||||
|
istAnbieter,
|
||||||
|
zustandErzeugen,
|
||||||
|
} from '@/server/kalender/verbinden'
|
||||||
|
import { angemeldet } from '@/server/berechtigung'
|
||||||
|
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
|
||||||
|
*
|
||||||
|
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
|
||||||
|
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
|
||||||
|
* die Möglichkeit anderswo gäbe.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ anbieter: string }> },
|
||||||
|
) {
|
||||||
|
const { anbieter: roh } = await params
|
||||||
|
if (!istAnbieter(roh)) notFound()
|
||||||
|
|
||||||
|
const anbieter = alsAnbieter(roh)
|
||||||
|
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||||
|
|
||||||
|
const benutzer = await angemeldet()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zustand = zustandErzeugen(anbieter, benutzer.id)
|
||||||
|
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
|
||||||
|
process.env.APP_URL ?? '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,35 @@ import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean }
|
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
|
||||||
|
|
||||||
const EINTRAEGE: Eintrag[] = [
|
|
||||||
{ pfad: '/', name: 'Start' },
|
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
|
||||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Kopfleiste({
|
export function Kopfleiste({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
rolle,
|
rolle,
|
||||||
instanzName,
|
instanzName,
|
||||||
|
inbox = 0,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
rolle: Rolle
|
rolle: Rolle
|
||||||
instanzName: string
|
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: '/woche', name: 'Woche' },
|
||||||
|
{ pfad: '/projekte', name: 'Projekte' },
|
||||||
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
|
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||||
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
|
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||||
|
]
|
||||||
|
|
||||||
const pfad = usePathname()
|
const pfad = usePathname()
|
||||||
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||||||
|
|
||||||
@@ -68,6 +77,14 @@ export function Kopfleiste({
|
|||||||
<Shield className="mr-1 inline size-3.5" aria-hidden />
|
<Shield className="mr-1 inline size-3.5" aria-hidden />
|
||||||
)}
|
)}
|
||||||
{eintrag.name}
|
{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>
|
</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,15 @@
|
|||||||
|
/**
|
||||||
|
* Wird von Next einmal beim Hochfahren des Servers ausgeführt.
|
||||||
|
*
|
||||||
|
* Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch
|
||||||
|
* node-cron. Der Demo-Modus startet bewusst keine Jobs — die
|
||||||
|
* Screenshot-Instanz soll sich nicht selbst verändern, während sie
|
||||||
|
* abfotografiert wird.
|
||||||
|
*/
|
||||||
|
export async function register(): Promise<void> {
|
||||||
|
if (process.env.NEXT_RUNTIME !== 'nodejs') return
|
||||||
|
if (process.env.DEMO_MODUS === 'true') return
|
||||||
|
|
||||||
|
const { jobsStarten } = await import('@/server/jobs')
|
||||||
|
jobsStarten()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||