Compare commits
@@ -6,7 +6,22 @@ node_modules
|
||||
logs
|
||||
sicherungen
|
||||
coverage
|
||||
|
||||
# Nur für die Entwicklung. Halten den Kontext klein und sorgen dafür, dass ein
|
||||
# defektes Werkzeugskript nicht den Bau des Abbilds blockiert – geprüft werden
|
||||
# sie ohnehin von "npm run pruefen".
|
||||
e2e
|
||||
screenshots
|
||||
test
|
||||
test-results
|
||||
playwright-report
|
||||
docs/bilder
|
||||
playwright.config.ts
|
||||
playwright.screenshots.config.ts
|
||||
vitest.config.ts
|
||||
docs
|
||||
devlog
|
||||
*.sh
|
||||
scripts/pruefen.mjs
|
||||
scripts/integrationstest.ts
|
||||
scripts/migrationstest.ts
|
||||
scripts/screenshots.ts
|
||||
|
||||
@@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer
|
||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||
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 -------------------------------------------------------------
|
||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
@@ -39,3 +39,4 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
screenshot-lauf.log
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Änderungen
|
||||
|
||||
Alle nennenswerten Änderungen an diesem Projekt stehen hier.
|
||||
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||
die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Projektgerüst mit Next 16 (App Router, `output: "standalone"`), React 19,
|
||||
TypeScript strict, Tailwind 4 und shadcn-Primitiven.
|
||||
- `npm run pruefen` bündelt Prettier, ESLint, `tsc --noEmit`, Vitest und
|
||||
ShellCheck. ShellCheck läuft über das Docker-Abbild, wenn es lokal fehlt.
|
||||
- Datenmodell für Benutzer mit Rolle, OIDC-Konten, Passkeys, Bereiche und
|
||||
Audit-Einträge; PostgreSQL 16 über Prisma 7 mit pg-Treiberadapter.
|
||||
- Anmeldung über einen generischen OIDC-Anbieter, über Passkeys
|
||||
(SimpleWebAuthn) und wahlweise über E-Mail und Passwort.
|
||||
- Rollen `BENUTZER` und `ADMIN`. Jede schreibende Aktion prüft die Rolle
|
||||
serverseitig in der Seite **und** noch einmal in der Server Action.
|
||||
- Audit-Protokoll für jede schreibende Aktion, mit Protokollseite für die
|
||||
Verwaltung. Details werden vor dem Speichern gesäubert.
|
||||
- Instanzsteuerung über `INSTANZ=firma|daheim`. Was eine Instanz nicht darf,
|
||||
liefert dort 404 und lädt seine Konfiguration gar nicht erst.
|
||||
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag.
|
||||
- Zeitrechnung durchgehend in Europe/Berlin, mit Tagesgrenzen, Wochenbeginn
|
||||
am Montag und deutschen Anzeigeformaten.
|
||||
- `/api/health` mit Status, Version, Instanz und Erreichbarkeit der Datenbank.
|
||||
- Hell und Dunkel, Navigation mit Rollenfilter, Oberfläche auf Deutsch.
|
||||
- Dockerfile in drei Stufen und Compose-Stapel mit eigenem Migrationsdienst.
|
||||
- `backup.sh` (pg_dump im Custom-Format, `.env` und Uploads),
|
||||
`update.sh` (mit Rückrollpfad), `scripts/release.sh` und `scripts/zeit.sh`.
|
||||
- Tests: Unit mit Vitest, Integrationstest und Migrationstest gegen eine
|
||||
echte Datenbank, E2E-Rauchtest mit Playwright.
|
||||
- `npm run screenshots` nimmt die Handbuchbilder in einer Wegwerf-Instanz mit
|
||||
ausschließlich erfundenen Daten auf.
|
||||
- README, Schnellstart, Handbuch, ROADMAP und DEVLOG.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich,
|
||||
damit sich nicht abfragen lässt, wer ein Konto hat.
|
||||
- Sitzungen als JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
|
||||
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
|
||||
- Die WebAuthn-Herausforderung wird nach einem Versuch verworfen, gleich ob
|
||||
er geglückt ist.
|
||||
- Der Demo-Seed verweigert den Dienst ohne `DEMO_MODUS=true`.
|
||||
- `allowDangerousEmailAccountLinking` ist bewusst aktiv: je Instanz gibt es
|
||||
genau einen vertrauenswürdigen OIDC-Anbieter, der die Adresse selbst prüft.
|
||||
@@ -0,0 +1,52 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planer – Abbild für den Betrieb.
|
||||
#
|
||||
# Die Stufe "bauen" dient zugleich als Abbild für den Migrationsdienst: die
|
||||
# Prisma-CLI bringt eine ganze Reihe Module mit, die im schlanken
|
||||
# Laufzeitabbild nichts zu suchen haben. Siehe docker-compose.yml.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FROM node:24-alpine AS abhaengigkeiten
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
# Die Bauwerkzeuge werden gebraucht und bleiben in dieser Stufe.
|
||||
RUN npm ci --ignore-scripts && npm rebuild prisma @prisma/engines esbuild unrs-resolver
|
||||
|
||||
|
||||
FROM node:24-alpine AS bauen
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Der Prisma-Client wird erzeugt, bevor Next baut.
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
|
||||
FROM node:24-alpine AS betrieb
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
TZ=Europe/Berlin
|
||||
|
||||
RUN apk add --no-cache tzdata curl \
|
||||
&& addgroup --system --gid 1001 planer \
|
||||
&& adduser --system --uid 1001 --ingroup planer planer
|
||||
|
||||
# Die standalone-Ausgabe bringt nur die wirklich benutzten Module mit.
|
||||
COPY --from=bauen --chown=planer:planer /app/.next/standalone ./
|
||||
COPY --from=bauen --chown=planer:planer /app/.next/static ./.next/static
|
||||
COPY --from=bauen --chown=planer:planer /app/public ./public
|
||||
|
||||
USER planer
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
|
||||
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,349 @@
|
||||
# Planer
|
||||
|
||||
Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.
|
||||
|
||||
Die Kernidee: Kalender aus Microsoft 365 (Firma, Gewerbe) und Google (privat)
|
||||
lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||
wird.
|
||||
|
||||
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
|
||||
> 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`
|
||||
zur ersten Anmeldung
|
||||
- **[Handbuch](docs/handbuch/)** – Anleitung für den Alltag
|
||||
- **[Fahrplan](ROADMAP.md)** – was geplant ist und was bewusst nicht
|
||||
- **[Änderungen](CHANGELOG.md)** – was sich je Version getan hat
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
### 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
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
serverseitig geprüft und hinterlässt einen Protokolleintrag
|
||||
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag
|
||||
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
|
||||
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
|
||||
|
||||
### Geplant
|
||||
|
||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Bildergalerie
|
||||
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Woche |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Kalender |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
|
||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||
|
||||
## Die zwei Instanzen
|
||||
|
||||
Ein Abbild, zwei Deployments, gesteuert über `INSTANZ`. Die Umschaltung wird
|
||||
**serverseitig erzwungen**: Was eine Instanz nicht darf, liefert dort 404 und
|
||||
lädt seine Konfiguration gar nicht erst.
|
||||
|
||||
| | `firma` | `daheim` |
|
||||
| -------------- | -------------------------------------- | ------------------------------ |
|
||||
| Anmeldung | OIDC Entra ID | OIDC authentik |
|
||||
| Kalenderkonten | Graph (Firmen-Tenant) | Graph (Gewerbe-Tenant), Google |
|
||||
| Mail | Graph Firmenpostfach | Graph Gewerbepostfach, Gmail |
|
||||
| Integrationen | Zammad, Wartungsplaner, Spiegel senden | Telegram, Spiegel empfangen |
|
||||
|
||||
Ein falsch geschriebener Wert bricht den Start ab, statt still auf einen
|
||||
Standard zu fallen — in der Firma dürfen keine Provider anlaufen, die dort
|
||||
nichts zu suchen haben.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
||||
`.env` gehört niemals ins Git.
|
||||
|
||||
### Pflicht
|
||||
|
||||
| Variable | Beispiel | Bedeutung |
|
||||
| ----------------- | ---------------------------------------------------- | ---------------------------------------------- |
|
||||
| `INSTANZ` | `daheim` | `firma` oder `daheim` |
|
||||
| `APP_URL` | `https://planer.example.de` | Öffentliche Adresse, ohne Schrägstrich am Ende |
|
||||
| `DATABASE_URL` | `postgresql://planer:…@db:5432/planer?schema=public` | Verbindung zur Datenbank |
|
||||
| `AUTH_SECRET` | _(32 Byte Base64)_ | Signiert Sitzungen. `openssl rand -base64 32` |
|
||||
| `WEBAUTHN_RP_ID` | `planer.example.de` | Nackte Domain, ohne Schema und Port |
|
||||
| `WEBAUTHN_ORIGIN` | `https://planer.example.de` | Exakt die Herkunft im Browser |
|
||||
|
||||
### Datenbank im Compose-Stapel
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
| ------------------- | ---------- | ---------------------------------------------- |
|
||||
| `POSTGRES_USER` | `planer` | Benutzer der Datenbank |
|
||||
| `POSTGRES_PASSWORD` | _(keiner)_ | Pflicht. Muss zu `DATABASE_URL` passen |
|
||||
| `POSTGRES_DB` | `planer` | Name der Datenbank |
|
||||
| `POSTGRES_PORT` | `5432` | Nach außen veröffentlichter Port |
|
||||
| `APP_PORT` | `3000` | Nach außen veröffentlichter Port der Anwendung |
|
||||
|
||||
### Anmeldung
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
| -------------------- | -------- | ------------------------------------------ |
|
||||
| `AUTH_TRUST_HOST` | `false` | Hinter einem Reverse Proxy auf `true` |
|
||||
| `ANMELDUNG_PASSWORT` | `false` | Anmeldung mit E-Mail und Passwort erlauben |
|
||||
| `OIDC_NAME` | _(leer)_ | Beschriftung des Anmeldeknopfs |
|
||||
| `OIDC_ISSUER` | _(leer)_ | Issuer-URL des Anbieters |
|
||||
| `OIDC_CLIENT_ID` | _(leer)_ | Client-ID |
|
||||
| `OIDC_CLIENT_SECRET` | _(leer)_ | Client-Secret |
|
||||
| `WEBAUTHN_RP_NAME` | `Planer` | Name, den das Gerät beim Passkey anzeigt |
|
||||
|
||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||
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
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
| ---------------- | --------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `ADMIN_EMAIL` | _(leer)_ | Erstes Verwaltungskonto, wird vom Seed angelegt |
|
||||
| `ADMIN_PASSWORT` | _(leer)_ | Mindestens 12 Zeichen. Ohne den Wert entsteht ein Konto, das nur OIDC oder Passkey kennt |
|
||||
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||
| `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
|
||||
|
||||
### Starten und stoppen
|
||||
|
||||
```bash
|
||||
docker compose up -d # starten
|
||||
docker compose logs -f app # Protokoll mitlesen
|
||||
docker compose down # stoppen, Daten bleiben
|
||||
```
|
||||
|
||||
Der Stapel besteht aus drei Diensten: `db`, ein Einmaldienst `migration`, der
|
||||
die Migrationen einspielt und sich beendet, und `app`.
|
||||
|
||||
### Healthcheck
|
||||
|
||||
```bash
|
||||
curl https://planer.example.de/api/health
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"instanz": "daheim",
|
||||
"datenbank": "ok",
|
||||
"zeit": "2026-03-17T08:20:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Die Auskunft ist bewusst ohne Anmeldung erreichbar und enthält deshalb keine
|
||||
Zählstände, Namen oder Adressen. Bei `"status": "fehler"` antwortet der
|
||||
Endpunkt mit HTTP 503.
|
||||
|
||||
### Aktualisieren
|
||||
|
||||
```bash
|
||||
./update.sh # auf den neuesten Tag
|
||||
./update.sh --nur-pruefen # nur zeigen, was passieren würde
|
||||
./update.sh --version v0.2.0 # auf eine bestimmte Version
|
||||
./update.sh --ja # ohne Rückfrage
|
||||
```
|
||||
|
||||
Das Skript prüft die Voraussetzungen, zeigt die Änderungen aus dem CHANGELOG,
|
||||
legt eine Sicherung an, wechselt auf den Ziel-Tag, baut, startet und wartet
|
||||
auf einen gesunden `/api/health` mit der erwarteten Version. Klappt das nicht,
|
||||
rollt es auf die vorherige Version zurück und nennt den Pfad der Sicherung.
|
||||
Jeder Lauf wird nach `logs/update-<Datum>.log` protokolliert.
|
||||
|
||||
Die **Datenbank wird beim Zurückrollen nicht automatisch zurückgespielt** —
|
||||
das wäre bei einem Teilfehler gefährlicher als hilfreich. Das Skript nennt den
|
||||
Befehl dafür.
|
||||
|
||||
### Sicherung und Wiederherstellung
|
||||
|
||||
```bash
|
||||
./backup.sh # Datenbank, .env und Uploads
|
||||
./backup.sh --tage 90 # längere Aufbewahrung
|
||||
./backup.sh --nur-datenbank # nur der Dump
|
||||
```
|
||||
|
||||
Je Lauf entstehen zwei Dateien in `sicherungen/`:
|
||||
ein `pg_dump` im Custom-Format und ein tar.gz mit `.env` und `uploads`.
|
||||
|
||||
Wiederherstellen:
|
||||
|
||||
```bash
|
||||
# Datenbank
|
||||
docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists \
|
||||
< sicherungen/planer-db-2026-03-17_02-00-00.dump
|
||||
|
||||
# Dateien
|
||||
tar -xzf sicherungen/planer-dateien-2026-03-17_02-00-00.tar.gz
|
||||
```
|
||||
|
||||
Die Sicherung enthält `.env` und damit alle Geheimnisse. Sie gehört so
|
||||
geschützt wie die Datenbank selbst.
|
||||
|
||||
Nächtlich per cron:
|
||||
|
||||
```cron
|
||||
0 2 * * * cd /opt/planer && ./backup.sh --tage 30 >> logs/backup.log 2>&1
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
docker compose up -d db # nur die Datenbank
|
||||
npm run db:migrieren # Migrationen einspielen
|
||||
npm run db:seed # Bereiche und Verwaltungskonto
|
||||
npm run dev # Entwicklungsserver
|
||||
```
|
||||
|
||||
### Prüfungen
|
||||
|
||||
```bash
|
||||
npm run pruefen # Prettier, ESLint, tsc, Vitest, ShellCheck
|
||||
npm run test:integration # gegen eine echte Datenbank
|
||||
npm run test:migration # Migrationen gegen eine Wegwerf-Datenbank
|
||||
npm run test:e2e # Playwright gegen die gebaute Anwendung
|
||||
npm run screenshots # Handbuchbilder neu aufnehmen
|
||||
```
|
||||
|
||||
`npm run pruefen` muss vor jedem Commit grün sein. Fehlt `shellcheck` lokal,
|
||||
wird das Docker-Abbild `koalaman/shellcheck` benutzt.
|
||||
|
||||
### Aufbau
|
||||
|
||||
```
|
||||
prisma/ Schema, Migrationen, Seed, Demo-Seed
|
||||
scripts/ Prüf-, Test- und Betriebsskripte
|
||||
src/app/ Seiten und API-Routen; (app) = angemeldeter Bereich
|
||||
src/components/ Bausteine; ui/ enthält die Primitiven
|
||||
src/lib/ Reine Logik ohne DB und ohne Systemzeit, je Datei ein Test
|
||||
src/server/ DB-nahe Dienste, Server Actions, Provider
|
||||
e2e/ Playwright-Rauchtests
|
||||
screenshots/ Aufnahme der Handbuchbilder
|
||||
docs/ Schnellstart, Handbuch, Bilder
|
||||
devlog/ Entwicklungstagebuch und Zeiterfassung
|
||||
```
|
||||
|
||||
Reine Funktionen bekommen die aktuelle Zeit als Parameter, damit sie ohne
|
||||
Tricks prüfbar bleiben. Die Tests laufen bewusst in UTC — so fällt sofort auf,
|
||||
wenn irgendwo die Ortszeit des Servers durchschlägt statt Europe/Berlin.
|
||||
|
||||
### Bekannte Meldungen
|
||||
|
||||
`npm audit` meldet vier Funde in transitiven Abhängigkeiten der
|
||||
**`prisma`-CLI** (`deepmerge-ts` über `@prisma/config`, `mysql2`). Beide sind
|
||||
Entwicklungsabhängigkeiten und erreichen das Laufzeitabbild nicht; MySQL wird
|
||||
ohnehin nie geladen. Der angebotene Fix wäre ein Rückschritt auf Prisma 6.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit
|
||||
sich nicht abfragen lässt, wer hier ein Konto hat.
|
||||
- Sitzungen sind JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
|
||||
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
|
||||
- Jede schreibende Aktion prüft die Rolle in der Seite **und** noch einmal in
|
||||
der Server Action. Die Oberfläche blendet Knöpfe nur zusätzlich aus.
|
||||
- Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach
|
||||
Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
|
||||
- Keine Geheimnisse, Tokens, Mailinhalte oder Termin-Betreffs in Protokollen.
|
||||
|
||||
Gefundene Schwachstellen bitte nicht als Issue anlegen, sondern direkt melden.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Privates Projekt, keine Lizenz zur Weiterverwendung.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Fahrplan
|
||||
|
||||
Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
|
||||
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
|
||||
| Meilenstein | Ziel | Status |
|
||||
| --------------------------------------- | ---------------------------- | -------- |
|
||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||
| [M8](#m8--mcp) | MCP | geplant |
|
||||
|
||||
---
|
||||
|
||||
## M1 – Fundament
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.1.0.**
|
||||
|
||||
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
|
||||
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
|
||||
|
||||
- [x] Projektgerüst: Next 16, React 19, TypeScript strict, Tailwind 4
|
||||
- [x] `npm run pruefen`: Prettier, ESLint, tsc, Vitest, ShellCheck
|
||||
- [x] Datenmodell für Anmeldung, Passkeys, Bereiche und Audit
|
||||
- [x] Anmeldung über OIDC, Passkey und wahlweise Passwort
|
||||
- [x] Rollen `BENUTZER` und `ADMIN`, serverseitig in Seite **und** Server Action
|
||||
- [x] Audit-Eintrag je schreibender Aktion, Details werden gesäubert
|
||||
- [x] Instanzsteuerung `INSTANZ=firma|daheim` mit 404 statt Hinweis
|
||||
- [x] Bereiche verwalten: Farbe, Arbeitsfenster, Tageskapazität
|
||||
- [x] Protokollseite für die Verwaltung
|
||||
- [x] `/api/health` mit Status, Version und Instanz
|
||||
- [x] Dockerfile, Compose-Stapel mit eigenem Migrationsdienst
|
||||
- [x] `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
|
||||
- [x] Unit-, Integrations-, Migrations- und E2E-Tests
|
||||
- [x] Screenshot-Kette mit eigener Wegwerf-Instanz
|
||||
- [x] README, Schnellstart, Handbuch, CHANGELOG, DEVLOG
|
||||
|
||||
**Bewusst verschoben:** `Aufgabe` und `Zeitblock` kommen mit M2 und M4,
|
||||
`Kalenderkonto` mit M3 — jeweils mit den Feldern, die der Provider dann
|
||||
wirklich braucht, statt heute geraten.
|
||||
|
||||
## M2 – Aufgaben und Projekte
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.2.0.**
|
||||
|
||||
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
||||
|
||||
- [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
||||
- [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
||||
- [x] Inbox: schnell erfassen, später einsortieren
|
||||
- [x] Ideen getrennt von Aufgaben
|
||||
- [x] Projekte mit Arbeitspaketen und Meilensteinen
|
||||
- [x] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
||||
- [x] Handbuchseiten und Bilder
|
||||
|
||||
**Bewusst offen gelassen:** Kein Wiederholungsrhythmus für Aufgaben und keine
|
||||
Verknüpfung von Aufgaben untereinander — beides steht unter „Ideen für
|
||||
später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
||||
|
||||
## M3 – Kalender lesen
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||
|
||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||
|
||||
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||
- [x] Hintergrundjob mit node-cron
|
||||
|
||||
## 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
|
||||
Verschiebungen von dort zurückholen.
|
||||
|
||||
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||
- [x] Wochenansicht mit `@dnd-kit/core`
|
||||
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||
- [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
|
||||
|
||||
- [ ] Web-Push mit `web-push`
|
||||
- [ ] Tagesüberblick per E-Mail über nodemailer
|
||||
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
|
||||
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
|
||||
|
||||
## M6 – Integrationen
|
||||
|
||||
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
|
||||
- [ ] Telegram (nur `daheim`)
|
||||
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
|
||||
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
|
||||
|
||||
## M7 – Schnellerfassung und API
|
||||
|
||||
- [ ] Erfassung vom Handy, auch offline
|
||||
- [ ] Öffentliche API mit Token je Anwendung
|
||||
- [ ] Rechte je Token, jede Nutzung im Protokoll
|
||||
|
||||
## M8 – MCP
|
||||
|
||||
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
|
||||
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
|
||||
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
|
||||
|
||||
---
|
||||
|
||||
## Ideen für später
|
||||
|
||||
- Auswertung: wie viel Zeit ging tatsächlich in welchen Bereich
|
||||
- Wiederkehrende Aufgaben mit eigenem Rhythmus
|
||||
- Vorlagen für wiederkehrende Projekte
|
||||
- Verknüpfung von Aufgaben untereinander (blockiert, gehört zu)
|
||||
- Export der eigenen Daten als JSON
|
||||
|
||||
## Bewusst nicht geplant
|
||||
|
||||
- **Wiederkehrende Wartungsaufgaben mit Nachweispflicht.** Die bleiben im
|
||||
Wartungsplaner. Der Planer liest nur Fälligkeiten über dessen API.
|
||||
- **Mehrbenutzerbetrieb mit geteilten Aufgaben.** Der Planer ist für eine
|
||||
Person gedacht; Kollegen melden sich in der Firmeninstanz nur an ihren
|
||||
eigenen Daten an.
|
||||
- **Eigene Kalenderdarstellung als Ersatz für Outlook.** Der Planer schreibt
|
||||
in die vorhandenen Kalender, er ersetzt sie nicht.
|
||||
- **Zeiterfassung zur Abrechnung.** Dafür gibt es andere Werkzeuge.
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sichert Datenbank, .env und Uploads des Planers.
|
||||
# Wiederherstellung: siehe README.md, Abschnitt "Sicherung und Wiederherstellung".
|
||||
set -euo pipefail
|
||||
|
||||
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$WURZEL"
|
||||
|
||||
ZIEL="${WURZEL}/sicherungen"
|
||||
AUFBEWAHRUNG_TAGE=30
|
||||
NUR_DATENBANK=0
|
||||
|
||||
hilfe() {
|
||||
cat <<'ENDE'
|
||||
Verwendung: backup.sh [Optionen]
|
||||
|
||||
--ziel <Pfad> Ablageort der Sicherung (Standard: ./sicherungen)
|
||||
--tage <Anzahl> Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt)
|
||||
--nur-datenbank Nur den Datenbank-Dump erzeugen
|
||||
--hilfe Diese Übersicht
|
||||
|
||||
Erzeugt je Lauf zwei Dateien:
|
||||
planer-db-<Zeitstempel>.dump Datenbank im Custom-Format von pg_dump
|
||||
planer-dateien-<Zeitstempel>.tar.gz .env und der Ordner uploads
|
||||
ENDE
|
||||
}
|
||||
|
||||
meldung() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; }
|
||||
fehler() {
|
||||
printf '%s Fehler: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ziel)
|
||||
ZIEL="${2:-}"
|
||||
[ -n "$ZIEL" ] || fehler '--ziel braucht einen Pfad.'
|
||||
shift 2
|
||||
;;
|
||||
--tage)
|
||||
AUFBEWAHRUNG_TAGE="${2:-}"
|
||||
case "$AUFBEWAHRUNG_TAGE" in
|
||||
''|*[!0-9]*) fehler '--tage braucht eine ganze Zahl.' ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--nur-datenbank)
|
||||
NUR_DATENBANK=1
|
||||
shift
|
||||
;;
|
||||
--hilfe|-h)
|
||||
hilfe
|
||||
exit 0
|
||||
;;
|
||||
*) fehler "Unbekannte Option '$1'. 'backup.sh --hilfe' zeigt die Übersicht." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fehler 'docker fehlt.'
|
||||
[ -f "$WURZEL/.env" ] || fehler '.env fehlt – im Projektverzeichnis aufrufen.'
|
||||
|
||||
# Nur die benötigten Werte lesen, nicht die ganze Datei ausführen.
|
||||
lies_env() {
|
||||
local schluessel="$1" standard="${2:-}"
|
||||
local wert
|
||||
wert="$(grep -E "^${schluessel}=" "$WURZEL/.env" | tail -1 | cut -d= -f2- || true)"
|
||||
wert="${wert%\"}"
|
||||
wert="${wert#\"}"
|
||||
printf '%s' "${wert:-$standard}"
|
||||
}
|
||||
|
||||
PG_BENUTZER="$(lies_env POSTGRES_USER planer)"
|
||||
PG_DATENBANK="$(lies_env POSTGRES_DB planer)"
|
||||
|
||||
DB_CONTAINER="$(docker compose ps -q db)"
|
||||
[ -n "$DB_CONTAINER" ] || fehler 'Der Datenbank-Container läuft nicht. Erst "docker compose up -d db".'
|
||||
|
||||
ZEITSTEMPEL="$(date '+%Y-%m-%d_%H-%M-%S')"
|
||||
mkdir -p "$ZIEL"
|
||||
|
||||
DUMP="$ZIEL/planer-db-${ZEITSTEMPEL}.dump"
|
||||
meldung "Datenbank sichern nach $DUMP"
|
||||
# -Fc ist das Custom-Format: komprimiert und mit pg_restore selektiv einspielbar.
|
||||
docker exec "$DB_CONTAINER" pg_dump -U "$PG_BENUTZER" -d "$PG_DATENBANK" -Fc > "$DUMP"
|
||||
|
||||
if [ ! -s "$DUMP" ]; then
|
||||
rm -f "$DUMP"
|
||||
fehler 'Der Dump ist leer geblieben.'
|
||||
fi
|
||||
meldung "Datenbank gesichert ($(du -h "$DUMP" | cut -f1))."
|
||||
|
||||
if [ "$NUR_DATENBANK" -eq 0 ]; then
|
||||
DATEIEN="$ZIEL/planer-dateien-${ZEITSTEMPEL}.tar.gz"
|
||||
meldung "Dateien sichern nach $DATEIEN"
|
||||
ZU_SICHERN=(.env)
|
||||
[ -d "$WURZEL/uploads" ] && ZU_SICHERN+=(uploads)
|
||||
tar -czf "$DATEIEN" -C "$WURZEL" "${ZU_SICHERN[@]}"
|
||||
meldung "Dateien gesichert ($(du -h "$DATEIEN" | cut -f1))."
|
||||
fi
|
||||
|
||||
if [ "$AUFBEWAHRUNG_TAGE" -gt 0 ]; then
|
||||
meldung "Sicherungen älter als $AUFBEWAHRUNG_TAGE Tage entfernen."
|
||||
find "$ZIEL" -maxdepth 1 -type f -name 'planer-*' -mtime "+$AUFBEWAHRUNG_TAGE" -print -delete
|
||||
fi
|
||||
|
||||
meldung 'Sicherung abgeschlossen.'
|
||||
@@ -0,0 +1,396 @@
|
||||
# Entwicklungstagebuch
|
||||
|
||||
Neuester Eintrag oben. Die Dauer je Sitzung stammt aus `devlog/zeiten.csv`,
|
||||
die Übersicht aus `scripts/zeit.sh summe`.
|
||||
|
||||
## Zeit je Meilenstein
|
||||
|
||||
| Meilenstein | Art | Minuten | Stunden |
|
||||
| ----------- | ------ | ------- | -------- |
|
||||
| M1 | claude | 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
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 – M1: Fundament
|
||||
|
||||
**Dauer:** 161 Minuten (12:06–14:47), Art `claude`.
|
||||
|
||||
**Ziel der Sitzung:** Das Projekt von Grund auf aufsetzen: Gerüst,
|
||||
Datenbank, Anmeldung, Rollen, Audit, Instanzsteuerung, Betrieb und
|
||||
Dokumentation.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- Projektgerüst mit Next 16, React 19, TypeScript strict, Tailwind 4,
|
||||
alle Versionen exakt gepinnt
|
||||
- `npm run pruefen` als Sammelprüfung: Prettier, ESLint, `tsc`, Vitest,
|
||||
ShellCheck
|
||||
- Datenmodell und erste Migration: Benutzer mit Rolle, OIDC-Konten,
|
||||
Passkeys, WebAuthn-Herausforderungen, Bereiche, Audit
|
||||
- Anmeldung über OIDC, Passkey und Passwort, je Instanz zuschaltbar
|
||||
- Rollenprüfung in Seite **und** Server Action, Audit je Schreibaktion
|
||||
- Instanzsteuerung mit 404 statt Hinweis
|
||||
- Bereiche verwalten, Protokollseite, Startseite, Einstellungen
|
||||
- `/api/health`, Dockerfile, Compose-Stapel mit Migrationsdienst
|
||||
- `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
|
||||
- Unit-, Integrations-, Migrations- und E2E-Tests
|
||||
- Screenshot-Kette mit eigener Wegwerf-Instanz
|
||||
- README, Schnellstart, Handbuch, ROADMAP, CHANGELOG
|
||||
|
||||
### Entscheidungen mit Begründung
|
||||
|
||||
**Prisma 7.10.0 statt der notierten Fassung 6.** Fassung 6 ist überholt,
|
||||
Fassung 8 liegt nur als RC auf `latest`. Prisma 7 nimmt die Verbindung
|
||||
nicht mehr im Schema entgegen — sie steht in `prisma.config.ts`, der
|
||||
Client bekommt den pg-Treiberadapter.
|
||||
|
||||
**TypeScript 6.0.3 statt 7.0.2.** TS 7 ist zwar `latest`, aber
|
||||
`typescript-eslint@8.70` verlangt `typescript >=4.8.4 <6.1.0`. Mit TS 7
|
||||
wäre die Lint-Kette kaputt gewesen.
|
||||
|
||||
**ESLint 9.39.5 statt 10.** Das in `eslint-config-next@16` gebündelte
|
||||
`eslint-plugin-react` ruft noch `context.getFilename()`, das ESLint 10
|
||||
entfernt hat. Der Lint-Lauf brach reproduzierbar ab.
|
||||
|
||||
**Eigener Auth.js-Adapter.** Der mitgelieferte `@auth/prisma-adapter`
|
||||
greift fest auf `prisma.user` und `prisma.account` zu; unsere Modelle
|
||||
heißen `Benutzer` und `Konto`. Der Adapter ist schmal und ersparte es,
|
||||
die Fachbegriffe der Konvention zu opfern.
|
||||
|
||||
**Passkeys direkt über SimpleWebAuthn 14.** Der WebAuthn-Provider von
|
||||
Auth.js ist als experimentell markiert und auf SimpleWebAuthn 9
|
||||
festgelegt. Direkt angebunden bleibt die Fassung aktuell und der Ablauf
|
||||
nachvollziehbar. Für die npm-Auflösung brauchte es `overrides`, weil
|
||||
next-auth die älteren Fassungen als optionale Peers deklariert.
|
||||
|
||||
**Sitzungen als JWT, Rolle trotzdem aus der Datenbank.** Credentials-
|
||||
Provider verlangen JWT. Damit eine Sperre nicht bis zum Ablauf des Tokens
|
||||
wirkungslos bleibt, liest der `session`-Callback Rolle und Status bei
|
||||
jeder Anfrage frisch. Die Tabellen `sitzung` und `verifizierungs_token`
|
||||
wären damit tot und wurden wieder entfernt, solange es nur eine einzige
|
||||
Migration gab.
|
||||
|
||||
**Migrationen als eigener Compose-Dienst.** Der Versuch, nur die
|
||||
Prisma-CLI ins schlanke Laufzeitabbild zu kopieren, scheiterte an ihren
|
||||
transitiven Modulen (`effect` über `@prisma/config`). Alle mitzunehmen
|
||||
hätte das Abbild aufgebläht. Ein Einmaldienst aus der Bau-Stufe läuft vor
|
||||
der App und muss sauber durchlaufen.
|
||||
|
||||
**Aufgabe, Zeitblock und Kalenderkonto bewusst nicht modelliert.** Sie
|
||||
kommen mit M2, M4 und M3 — dann mit den Feldern, die der jeweilige
|
||||
Provider wirklich braucht, statt heute geraten.
|
||||
|
||||
### Probleme und Lösungen
|
||||
|
||||
| Problem | Lösung |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `prisma validate` lehnte `url` im Schema ab | Prisma 7 erwartet die Verbindung in `prisma.config.ts` und einen Treiberadapter im Client. |
|
||||
| Integrationstest brach nach der ersten erwarteten Verletzung ab | In PostgreSQL bricht ein fehlgeschlagener Befehl die ganze Transaktion ab. Der Test läuft jetzt ohne Sammeltransaktion; alle Testdaten tragen eine Kennung und werden am Ende entfernt. |
|
||||
| `prisma migrate diff` kannte `--from-url` nicht | In Prisma 7 heißen die Flaggen anders. Der Test vergleicht jetzt die eingespielte Wegwerf-Datenbank über `--from-config-datasource` gegen das Schema — und braucht damit keine Schattendatenbank. |
|
||||
| Nach der Anmeldung ging es nicht zum ursprünglichen Ziel zurück | Das Layout leitet um, bevor die Seite drankommt, und kennt seinen Pfad nicht. Ein schlanker Proxy reicht ihn als Kopfzeile durch — ohne Anmeldelogik, weil er im Edge-Umfeld läuft. |
|
||||
| Der Docker-Bau brach beim Sammeln der Seitendaten ab | Prisma-Client und Auth.js-Konfiguration wurden beim Laden des Moduls ausgewertet und brauchten Umgebungsvariablen, die es zur Bauzeit nicht gibt. Beide entstehen jetzt faul. |
|
||||
| Ein Typfehler in `scripts/screenshots.ts` blockierte den Abbild-Bau | Entwicklungsskripte gehören nicht in den Docker-Kontext. `.dockerignore` schließt sie aus; geprüft werden sie von `npm run pruefen`. |
|
||||
| ESLint beanstandete `setState` im Effekt | Der Hydratations-Trick im Farbschema-Knopf war unnötig: `next-themes` setzt die `dark`-Klasse vor der Hydratation, das erledigt CSS ohne Zustand. |
|
||||
|
||||
### Eigene Fehler, die ich korrigiert habe
|
||||
|
||||
- Die ersten vier Commits lagen direkt auf `main` statt auf einem
|
||||
Meilenstein-Zweig und ohne Attributionszeile. Historie umgebaut:
|
||||
`main` trägt nur noch einen leeren Wurzel-Commit, die Arbeit liegt auf
|
||||
`m1-fundament`.
|
||||
- Ein `git add -A` zog die Proxy-Umbenennung in den Doku-Commit. Wieder
|
||||
getrennt.
|
||||
- `scripts/screenshots.ts` wurde geschrieben, ohne danach `npm run
|
||||
pruefen` laufen zu lassen; der Typfehler fiel erst im Docker-Bau auf.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Die `origin`-URL fehlt noch. Push und Merge stehen ohnehin erst nach
|
||||
der Freigabe an.
|
||||
- Der Schnellstart endet beim Passkey statt beim ersten Kalenderkonto und
|
||||
der ersten Aufgabe — beides gibt es erst mit M3 und M2.
|
||||
- Der Demo-Seed enthält noch keine erfundenen Termine; einen
|
||||
Demo-Kalenderprovider gibt es erst mit M3.
|
||||
- Lokal weichen die Ports ab (Datenbank 5435, App 3001), weil 5432 und
|
||||
3000 auf diesem Rechner belegt sind. In `.env.example` stehen die
|
||||
üblichen Werte.
|
||||
|
||||
### Commits
|
||||
|
||||
- de1f923 chore: Projektgerüst und Werkzeugkette einrichten
|
||||
- 748d032 feat(datenbank): Datenmodell für Anmeldung, Bereiche und Audit
|
||||
- e2b00f4 feat(instanz): Instanzsteuerung mit serverseitigem Riegel
|
||||
- 19ca082 feat(zeit): Zeitrechnung und Anzeigeformate in Europe/Berlin
|
||||
- 4ff67c1 feat(oberflaeche): Grundlayout, Navigation und Farbschema
|
||||
- cc013fd feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5
|
||||
- e9a2b4b feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
|
||||
- 1876ae4 feat(betrieb): Health-Endpunkt und Seed für eine frische Datenbank
|
||||
- 24a799d chore: tsconfig-Anpassungen von Next 16 übernehmen
|
||||
- 126ad93 test: Integrations-, Migrations- und E2E-Tests ergänzen
|
||||
- f91d015 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
|
||||
- 56b5356 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
|
||||
- e3390c8 refactor: middleware in proxy umbenennen
|
||||
- a882731 chore: Screenshot-Kette und Demo-Seed
|
||||
- cf28ec1 test: Rollentrennung end-to-end absichern
|
||||
- 6d04bc2 fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
|
||||
- 688d9f5 docs: Handbuchbilder aufnehmen
|
||||
@@ -1 +1,5 @@
|
||||
datum;start;ende;minuten;meilenstein;art;notiz
|
||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
||||
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
|
||||
|
||||
|
@@ -0,0 +1,61 @@
|
||||
# Wegwerf-Instanz nur für "npm run screenshots".
|
||||
#
|
||||
# Eigenes Compose-Projekt (planer-screenshots), eigenes Volume, eigene Ports.
|
||||
# Sie kommt der Entwicklungs- oder Betriebsinstanz damit nicht in die Quere und
|
||||
# wird nach dem Lauf samt Volume wieder entfernt.
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: 'no'
|
||||
environment:
|
||||
POSTGRES_USER: demo
|
||||
POSTGRES_PASSWORD: demo
|
||||
POSTGRES_DB: demo
|
||||
TZ: Europe/Berlin
|
||||
ports:
|
||||
- '${SCREENSHOT_DB_PORT:-5455}:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U demo -d demo']
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
migration:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: bauen
|
||||
restart: 'no'
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
|
||||
command: node node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: betrieb
|
||||
restart: 'no'
|
||||
depends_on:
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: Europe/Berlin
|
||||
INSTANZ: daheim
|
||||
APP_URL: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
|
||||
AUTH_SECRET: nur-fuer-die-screenshot-instanz-nicht-geheim
|
||||
AUTH_TRUST_HOST: 'true'
|
||||
ANMELDUNG_PASSWORT: 'true'
|
||||
WEBAUTHN_RP_ID: 127.0.0.1
|
||||
WEBAUTHN_RP_NAME: Planer
|
||||
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||
DEMO_MODUS: 'true'
|
||||
ports:
|
||||
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
||||
@@ -20,15 +20,36 @@ services:
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
# Einmaldienst: spielt die Migrationen ein und beendet sich. Läuft aus der
|
||||
# Bau-Stufe, weil die Prisma-CLI eine Reihe Module braucht, die im schlanken
|
||||
# Laufzeitabbild nichts zu suchen haben.
|
||||
migration:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: bauen
|
||||
container_name: planer-migration
|
||||
restart: 'no'
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
|
||||
command: node node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: betrieb
|
||||
container_name: planer-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
@@ -45,21 +66,10 @@ services:
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
|
||||
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
|
||||
# Der Demo-Modus ist im Betrieb nie erlaubt.
|
||||
DEMO_MODUS: 'false'
|
||||
ports:
|
||||
- '${APP_PORT:-3000}:3000'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
db-daten:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Schnellstart
|
||||
|
||||
Von `git clone` bis zur ersten Anmeldung in höchstens zehn Minuten.
|
||||
Jeder Befehl ist zum Kopieren, jeder Schritt nennt das erwartete Ergebnis.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Werkzeug | Fassung | Prüfen mit |
|
||||
| ------------------ | ---------------- | ------------------------ |
|
||||
| Docker mit Compose | 25 oder neuer | `docker compose version` |
|
||||
| Git | 2.40 oder neuer | `git --version` |
|
||||
| Node.js | 22.12 oder neuer | `node -v` |
|
||||
|
||||
Node wird nur für die Entwicklung gebraucht. Für den reinen Betrieb genügen
|
||||
Docker und Git.
|
||||
|
||||
## 1. Holen und Verzeichnis wechseln
|
||||
|
||||
```bash
|
||||
git clone <REMOTE_URL> planer
|
||||
cd planer
|
||||
```
|
||||
|
||||
**Erwartet:** Das Verzeichnis `planer` enthält unter anderem `package.json`,
|
||||
`docker-compose.yml` und `.env.example`.
|
||||
|
||||
## 2. Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Jetzt die Pflichtwerte setzen. Die Geheimnisse werden erzeugt, nicht erfunden:
|
||||
|
||||
```bash
|
||||
# Signiert die Sitzungen. Ohne diesen Wert startet die Anwendung nicht.
|
||||
openssl rand -base64 32
|
||||
|
||||
# Ein Passwort für die Datenbank.
|
||||
openssl rand -hex 16
|
||||
```
|
||||
|
||||
Unter Windows ohne OpenSSL geht auch:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
Diese Werte in `.env` eintragen:
|
||||
|
||||
| Variable | Beispiel | Bedeutung |
|
||||
| ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `INSTANZ` | `daheim` | `firma` oder `daheim` – bestimmt, was es überhaupt gibt |
|
||||
| `APP_URL` | `http://localhost:3000` | Adresse ohne Schrägstrich am Ende |
|
||||
| `AUTH_SECRET` | _(erzeugt)_ | Signatur der Sitzungen |
|
||||
| `POSTGRES_PASSWORD` | _(erzeugt)_ | Passwort der Datenbank |
|
||||
| `DATABASE_URL` | `postgresql://planer:<Passwort>@localhost:5432/planer?schema=public` | muss dasselbe Passwort enthalten |
|
||||
| `WEBAUTHN_RP_ID` | `localhost` | nackte Domain, ohne Schema und Port |
|
||||
| `WEBAUTHN_ORIGIN` | `http://localhost:3000` | exakt wie im Browser, mit Port |
|
||||
| `ADMIN_EMAIL` | `du@example.de` | erstes Verwaltungskonto |
|
||||
| `ADMIN_PASSWORT` | _(mindestens 12 Zeichen)_ | nur nötig, wenn du dich ohne OIDC anmelden willst |
|
||||
|
||||
> **Sind die Ports 5432 oder 3000 auf deinem Rechner belegt?**
|
||||
> Dann `POSTGRES_PORT` beziehungsweise `APP_PORT` in `.env` auf einen freien
|
||||
> Wert setzen. `DATABASE_URL`, `APP_URL` und `WEBAUTHN_ORIGIN` müssen denselben
|
||||
> Port nennen.
|
||||
|
||||
**Erwartet:** `.env` liegt im Projektverzeichnis. Sie gehört niemals ins Git —
|
||||
`.gitignore` sorgt dafür.
|
||||
|
||||
## 3. Starten
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Beim ersten Mal dauert der Bau einige Minuten. Der Ablauf ist: Datenbank
|
||||
starten, Migrationen einspielen, Anwendung starten.
|
||||
|
||||
**Erwartet:**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
zeigt `db` als `healthy` und `app` als `Up`. Der Dienst `migration` taucht
|
||||
nicht mehr auf — er hat seine Arbeit getan und sich beendet.
|
||||
|
||||
## 4. Prüfen, ob alles läuft
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
**Erwartet:**
|
||||
|
||||
```json
|
||||
{ "status": "ok", "version": "0.1.0", "instanz": "daheim", "datenbank": "ok" }
|
||||
```
|
||||
|
||||
Steht dort `"datenbank": "fehler"`, stimmt `DATABASE_URL` nicht mit
|
||||
`POSTGRES_PASSWORD` überein.
|
||||
|
||||
## 5. Erste Bereiche und das Verwaltungskonto anlegen
|
||||
|
||||
```bash
|
||||
docker compose run --rm migration node node_modules/prisma/build/index.js db seed
|
||||
```
|
||||
|
||||
**Erwartet:** Die Ausgabe nennt die vier angelegten Bereiche (Firma, Gewerbe,
|
||||
Privat, Homelab) und bestätigt das Verwaltungskonto.
|
||||
|
||||
## 6. Anmelden
|
||||
|
||||
Im Browser <http://localhost:3000> öffnen.
|
||||
|
||||
**Erwartet:** Die Anmeldeseite. Mit `ADMIN_EMAIL` und `ADMIN_PASSWORT`
|
||||
anmelden — danach steht oben „Guten Tag" und darunter die vier Bereiche.
|
||||
|
||||
> Ist OIDC eingerichtet, erscheint zusätzlich ein Knopf mit dem Namen aus
|
||||
> `OIDC_NAME`. Der Rückleit-URI, den du beim Anbieter hinterlegen musst,
|
||||
> lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||
|
||||
## 7. Einen Passkey hinterlegen
|
||||
|
||||
Auf **Einstellungen** gehen, unter _Passkeys_ einen Namen für das Gerät
|
||||
eintragen (etwa „Arbeitsrechner") und auf **Anlegen** klicken. Das Gerät
|
||||
fragt nach Fingerabdruck, Gesicht oder PIN.
|
||||
|
||||
**Erwartet:** Der Passkey steht in der Liste. Ab jetzt genügt zum Anmelden die
|
||||
E-Mail-Adresse und **Mit Passkey anmelden**.
|
||||
|
||||
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
||||
> du dich aus, wenn ein Gerät verlorengeht.
|
||||
|
||||
## 8. Die erste Aufgabe erfassen
|
||||
|
||||
Auf der Startseite steht oben ein Feld **Was ist zu tun?**. Titel eintippen,
|
||||
Enter.
|
||||
|
||||
**Erwartet:** Eine kurze Bestätigung, und der Zähler neben **Inbox** im Menü
|
||||
springt auf 1. Dort kannst du die Aufgabe einem Bereich zuordnen.
|
||||
|
||||
Mehr dazu: [Aufgaben erfassen und wiederfinden](handbuch/aufgaben.md).
|
||||
|
||||
## 9. Aufräumen
|
||||
|
||||
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
||||
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
||||
|
||||
## Wie es weitergeht
|
||||
|
||||
- [Handbuch](handbuch/) – Anleitung für den Alltag, insbesondere
|
||||
[Aufgaben](handbuch/aufgaben.md), [Ideen](handbuch/ideen.md) und
|
||||
[Projekte](handbuch/projekte.md)
|
||||
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
||||
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
||||
|
||||
## Wenn etwas klemmt
|
||||
|
||||
| Symptom | Ursache und Abhilfe |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `INSTANZ fehlt in .env` beim Start | `INSTANZ` ist leer. Erlaubt sind nur `firma` und `daheim`. |
|
||||
| `Die Konfiguration ist unvollständig` | Die Meldung nennt die fehlende Variable. Sie nennt nie den Wert. |
|
||||
| `port is already allocated` | Ein anderer Dienst hält den Port. `APP_PORT` oder `POSTGRES_PORT` ändern. |
|
||||
| Passkey lässt sich nicht anlegen | `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. `WEBAUTHN_RP_ID` ist die nackte Domain, ohne `https://`. |
|
||||
| Anmeldung schlägt immer fehl | Bewusst gibt es nur eine Meldung für alle Fälle. Prüfe im Protokoll der Anwendung: `docker compose logs app`. |
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 34 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 |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 103 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 |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 234 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 |
@@ -0,0 +1,44 @@
|
||||
# Handbuch
|
||||
|
||||
Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
1. **[Anmelden](anmelden.md)** – die drei Wege hinein, Passkeys einrichten,
|
||||
was tun, wenn nichts mehr geht
|
||||
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
||||
Tageskapazität festlegen
|
||||
|
||||
## Im Alltag
|
||||
|
||||
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
||||
Inbox leeren, Filter, Status und Fälligkeit
|
||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
Fortschritt
|
||||
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
|
||||
|
||||
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
|
||||
## Was noch kommt
|
||||
|
||||
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||
|
||||
| Thema | Meilenstein |
|
||||
| ------------------------------- | ----------- |
|
||||
| Benachrichtigungen | M5 |
|
||||
| Integrationen | M6 |
|
||||
| Schnellerfassung vom Handy, API | M7 |
|
||||
| MCP | M8 |
|
||||
|
||||
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
||||
|
||||
---
|
||||
|
||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Anmelden
|
||||
|
||||
Es gibt drei Wege in den Planer. Welche davon zu sehen sind, hängt davon ab,
|
||||
was für deine Instanz eingerichtet wurde.
|
||||
|
||||

|
||||
|
||||
| Weg | Wann sinnvoll |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **Unternehmensanmeldung** (OIDC) | In der Firma der Normalfall. Ein Klick, die Anmeldung übernimmt Entra ID beziehungsweise authentik. |
|
||||
| **Passkey** | Der bequemste Weg am eigenen Gerät: Fingerabdruck, Gesicht oder Geräte-PIN, kein Passwort. |
|
||||
| **E-Mail und Passwort** | Der Rückfallweg. Oft nur daheim freigeschaltet. |
|
||||
|
||||
## Mit Passkey anmelden
|
||||
|
||||
1. E-Mail-Adresse eintragen.
|
||||
2. Auf **Mit Passkey anmelden** klicken.
|
||||
3. Das Gerät fragt nach Fingerabdruck, Gesicht oder PIN.
|
||||
|
||||
Fertig — du landest auf der Startseite.
|
||||
|
||||
> **Warum erst die E-Mail-Adresse?** Damit der Planer weiß, welche Passkeys er
|
||||
> anbieten soll. Er antwortet dabei für unbekannte Adressen genauso wie für
|
||||
> bekannte, damit sich von außen nicht abfragen lässt, wer hier ein Konto hat.
|
||||
|
||||
## Einen Passkey einrichten
|
||||
|
||||
Das geht erst, wenn du angemeldet bist — also beim ersten Mal über OIDC oder
|
||||
über E-Mail und Passwort.
|
||||
|
||||
1. Oben rechts auf **Einstellungen**.
|
||||
2. Im Abschnitt _Passkeys_ einen Namen für das Gerät eintragen, etwa
|
||||
„Arbeitsrechner" oder „Diensthandy".
|
||||
3. Auf **Anlegen** klicken und die Abfrage des Geräts bestätigen.
|
||||
|
||||

|
||||
|
||||
Der Passkey steht danach in der Liste, mit Anlegedatum und letzter Benutzung.
|
||||
|
||||
> **Lege zwei Passkeys an, auf zwei verschiedenen Geräten.** Sonst sperrst du
|
||||
> dich aus, sobald ein Gerät verlorengeht oder zurückgesetzt wird.
|
||||
|
||||
Unter jedem Eintrag steht, ob der Passkey **geräteübergreifend** ist — dann
|
||||
liegt er im Schlüsselbund von Apple, Google oder deinem Passwortmanager und ist
|
||||
auf deinen anderen Geräten ebenfalls verfügbar. Steht dort **nur dieses
|
||||
Gerät**, ist er an genau dieses Gerät gebunden.
|
||||
|
||||
## Einen Passkey entfernen
|
||||
|
||||
In den Einstellungen auf das Papierkorb-Symbol neben dem Eintrag. Der Passkey
|
||||
ist sofort ungültig; auf dem Gerät selbst bleibt ein Rest zurück, den du dort
|
||||
im Schlüsselbund löschen kannst.
|
||||
|
||||
## Abmelden
|
||||
|
||||
Oben rechts auf das Symbol mit dem Pfeil aus der Tür.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Was du siehst | Was dahintersteckt |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| „Anmeldung nicht möglich. Bitte Angaben prüfen." | Diese eine Meldung gilt für alle Fälle: falsches Passwort, unbekannte Adresse, gesperrtes Konto. Das ist Absicht — sonst ließe sich abfragen, wer hier ein Konto hat. |
|
||||
| Es passiert nichts nach dem Klick auf **Mit Passkey anmelden** | Du hast den Systemdialog abgebrochen. Einfach noch einmal klicken. |
|
||||
| „Dieses Gerät ist hier schon hinterlegt." | Für dieses Gerät gibt es bereits einen Passkey. Ein zweiter bringt nichts. |
|
||||
| Der Passkey-Knopf tut gar nichts | Der Browser unterstützt WebAuthn nicht, oder die Seite läuft nicht über HTTPS. Passkeys brauchen eine sichere Verbindung — `localhost` ausgenommen. |
|
||||
| Passkey lässt sich nicht anlegen | Meist stimmt die Konfiguration nicht: `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. Siehe [README](../../README.md). |
|
||||
|
||||
## Ausgesperrt?
|
||||
|
||||
Wenn kein Weg mehr hineinführt, hilft nur jemand mit Zugriff auf den Server.
|
||||
Dort lässt sich mit `ADMIN_EMAIL` und `ADMIN_PASSWORT` in der `.env` und einem
|
||||
erneuten Seed-Lauf wieder ein Passwort setzen — siehe
|
||||
[Schnellstart, Schritt 5](../SCHNELLSTART.md).
|
||||
|
||||
## Rollen
|
||||
|
||||
Unten in der Fußzeile und in den Einstellungen steht, welche Rolle du hast.
|
||||
|
||||
- **Benutzer** – sieht und plant die eigenen Sachen.
|
||||
- **Verwaltung** – darf zusätzlich Bereiche ändern und das
|
||||
[Protokoll](protokoll.md) einsehen.
|
||||
|
||||
Die Rolle vergibt die Verwaltung; sie lässt sich nicht selbst hochstufen.
|
||||
@@ -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,80 @@
|
||||
# Bereiche einrichten
|
||||
|
||||
Ein **Bereich** ist ein Lebensbereich: Firma, Gewerbe, Privat, Homelab. Jede
|
||||
Aufgabe und jeder Zeitblock gehört später zu genau einem davon.
|
||||
|
||||
Der Bereich bestimmt drei Dinge:
|
||||
|
||||
- **die Farbe**, an der du im Kalender sofort erkennst, worum es geht,
|
||||
- **das Arbeitsfenster** — die Tageszeit, in der für diesen Bereich überhaupt
|
||||
geplant wird,
|
||||
- **die Tageskapazität** — wie viele Minuten je Wochentag höchstens
|
||||
hineingehen.
|
||||
|
||||

|
||||
|
||||
> Bereiche ändern darf nur die **Verwaltung**. Als Benutzer siehst du sie,
|
||||
> aber ohne die Formulare.
|
||||
|
||||
## Einen Bereich anlegen
|
||||
|
||||
1. Im Menü auf **Bereiche**.
|
||||
2. Ganz unten unter _Neuen Bereich anlegen_ die Felder ausfüllen.
|
||||
3. Auf **Bereich anlegen** klicken.
|
||||
|
||||
| Feld | Bedeutung |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **Name** | Zwei bis 40 Zeichen, muss eindeutig sein |
|
||||
| **Farbe** | Wird zum Farbpunkt in Listen und später im Kalender |
|
||||
| **Arbeitsfenster von / bis** | Zwischen diesen Uhrzeiten wird geplant. Muss nach dem Beginn enden |
|
||||
| **Tageskapazität** | Minuten je Wochentag. 0 heißt: an diesem Tag gar nicht |
|
||||
| **In Benutzung** | Abgehakt lassen. Ohne Haken bleibt der Bereich erhalten, taucht aber nicht mehr zur Auswahl auf |
|
||||
| **Reihenfolge** | Kleinere Zahlen stehen weiter oben |
|
||||
|
||||
## Was eine sinnvolle Kapazität ist
|
||||
|
||||
Nicht die Zeit, die der Tag hergibt — die Zeit, die **wirklich für geplante
|
||||
Arbeit** übrig bleibt, nach Besprechungen, Störungen und Pausen.
|
||||
|
||||
Ein brauchbarer Anfang:
|
||||
|
||||
| Bereich | Montag bis Donnerstag | Freitag | Wochenende |
|
||||
| ------- | --------------------- | ------- | --------------- |
|
||||
| Firma | 360 min (6 h) | 240 min | 0 |
|
||||
| Gewerbe | 90 min | 60 min | Samstag 180 min |
|
||||
| Privat | 60 min | 120 min | je 240 min |
|
||||
| Homelab | 0 bis 60 min | 0 | Samstag 120 min |
|
||||
|
||||
Wenn am Ende der Woche regelmäßig etwas liegenbleibt, ist die Kapazität zu
|
||||
hoch angesetzt, nicht der Tag zu kurz.
|
||||
|
||||
> Die Tageskapazität darf nie länger sein als das Arbeitsfenster. Ein Fenster
|
||||
> von 08:00 bis 17:00 fasst höchstens 540 Minuten; trägst du mehr ein, weist
|
||||
> das Formular es ab.
|
||||
|
||||
## Einen Bereich ändern
|
||||
|
||||
Auf **Bearbeiten** unter dem Bereich klicken, ändern, **Speichern**. Mit
|
||||
**Schließen** verwirfst du, was du noch nicht gespeichert hast.
|
||||
|
||||
## Einen Bereich loswerden
|
||||
|
||||
Zwei Wege, und meistens ist der erste der richtige:
|
||||
|
||||
1. **Stilllegen** – den Haken bei _In Benutzung_ entfernen und speichern. Alles
|
||||
Bisherige bleibt erhalten, der Bereich taucht nur nicht mehr zur Auswahl auf.
|
||||
2. **Löschen** – der rote Knopf. Endgültig.
|
||||
|
||||
> Ab M2 hängen Aufgaben an Bereichen. Dann ist Stilllegen fast immer die
|
||||
> richtige Wahl, weil beim Löschen auch die Zuordnung verlorenginge.
|
||||
|
||||
Jede Änderung landet im [Protokoll](protokoll.md).
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
| Meldung | Was zu tun ist |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| „Diesen Namen gibt es schon." | Namen sind eindeutig. Vielleicht existiert der Bereich stillgelegt schon. |
|
||||
| „Das Arbeitsfenster muss nach seinem Beginn enden." | Von-Zeit liegt hinter der Bis-Zeit. Über Mitternacht hinweg geht nicht — dafür lieber zwei Bereiche. |
|
||||
| „Keine Tageskapazität darf länger sein als das Arbeitsfenster." | Entweder die Kapazität senken oder das Fenster weiten. |
|
||||
| „Dazu fehlt dir die Berechtigung." | Du bist als Benutzer angemeldet, nicht als Verwaltung. |
|
||||
@@ -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,57 @@
|
||||
# Protokoll
|
||||
|
||||
Jede schreibende Aktion im Planer hinterlässt einen Eintrag: wer, wann, was,
|
||||
und woran. Einträge lassen sich weder ändern noch löschen.
|
||||
|
||||
Die Seite steht nur der **Verwaltung** offen. Als Benutzer siehst du den
|
||||
Menüpunkt gar nicht erst; wer die Adresse direkt aufruft, landet auf einem
|
||||
Hinweis.
|
||||
|
||||

|
||||
|
||||
## Was dort steht
|
||||
|
||||
| Spalte | Bedeutung |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| **Zeitpunkt** | Datum und Uhrzeit in Europe/Berlin |
|
||||
| **Wer** | Die E-Mail-Adresse zum Zeitpunkt der Aktion |
|
||||
| **Was** | Die Aktion, in der Form `objekt.verb` |
|
||||
| **Objekt** | Die Art des betroffenen Objekts, dahinter die fachlichen Einzelheiten |
|
||||
|
||||
Angezeigt werden die letzten 100 Einträge, die neuesten zuerst.
|
||||
|
||||
## Die Aktionen
|
||||
|
||||
| Aktion | Bedeutung |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| `bereich.angelegt` | Ein neuer Bereich ist entstanden |
|
||||
| `bereich.geaendert` | Name, Farbe, Fenster oder Kapazität wurden geändert |
|
||||
| `bereich.geloescht` | Ein Bereich wurde entfernt |
|
||||
| `passkey.angelegt` | Jemand hat ein Gerät hinterlegt |
|
||||
| `passkey.entfernt` | Ein Gerät wurde entfernt |
|
||||
|
||||
Mit jedem Meilenstein kommen Aktionen hinzu.
|
||||
|
||||
## Was dort bewusst **nicht** steht
|
||||
|
||||
- Keine Passwörter, Hashes oder Tokens.
|
||||
- Keine Termin-Betreffs und keine Mailinhalte.
|
||||
- Keine Zugangsdaten der Kalenderkonten.
|
||||
|
||||
Das ist doppelt abgesichert: Solche Felder werden gar nicht erst übergeben,
|
||||
und vor dem Speichern ersetzt der Planer zusätzlich jedes Feld, dessen Name
|
||||
nach einem Geheimnis aussieht, durch `[entfernt]`. Wenn du dort `[entfernt]`
|
||||
liest, hat dieser zweite Riegel gegriffen.
|
||||
|
||||
## Warum die E-Mail-Adresse und nicht nur die Kennung
|
||||
|
||||
Wird ein Benutzer gelöscht, verschwinden seine Passkeys und verknüpften
|
||||
Konten — der Protokolleintrag bleibt. Die Verknüpfung zum Konto wird gelöst,
|
||||
die Adresse bleibt als Nachweis stehen. Sonst wäre das Protokoll nach einer
|
||||
Löschung wertlos.
|
||||
|
||||
## Aufbewahrung
|
||||
|
||||
Einträge werden nicht automatisch entfernt. Die Tabelle wächst langsam — ein
|
||||
Eintrag je Änderung, nicht je Seitenaufruf. Sollte sie einmal stören, gehört
|
||||
das Aufräumen in die Sicherungsroutine und nicht in die Anwendung.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Rauchtest der Anmeldung.
|
||||
*
|
||||
* Prüft den Weg, der in M1 wirklich trägt: Wer nicht angemeldet ist, kommt
|
||||
* nirgends hin; mit richtigem Passwort schon; und die Verwaltungsseite bleibt
|
||||
* verschlossen, wenn die Rolle nicht reicht.
|
||||
*
|
||||
* Voraussetzung: "npm run db:seed" ist gelaufen und ADMIN_EMAIL sowie
|
||||
* ADMIN_PASSWORT sind gesetzt.
|
||||
*/
|
||||
|
||||
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
||||
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||
|
||||
test.describe('Anmeldung', () => {
|
||||
test.skip(
|
||||
!EMAIL || !PASSWORT,
|
||||
'ADMIN_EMAIL und ADMIN_PASSWORT sind nicht gesetzt – ohne sie gibt es kein Testkonto.',
|
||||
)
|
||||
|
||||
test('leitet nicht Angemeldete zur Anmeldeseite', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/anmelden/)
|
||||
await expect(page.getByRole('heading', { name: 'Planer' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('merkt sich das Ziel und kehrt nach der Anmeldung dorthin zurück', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/bereiche')
|
||||
await expect(page).toHaveURL(/\/anmelden\?weiter=%2Fbereiche/)
|
||||
})
|
||||
|
||||
test('weist falsche Zugangsdaten ab, ohne zu verraten warum', async ({ page }) => {
|
||||
await page.goto('/anmelden')
|
||||
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await page.getByLabel('Passwort').fill('ganz-sicher-falsch-123')
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
|
||||
await expect(page.getByText('Anmeldung nicht möglich')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/anmelden/)
|
||||
})
|
||||
|
||||
test('lässt mit richtigem Passwort hinein und wieder hinaus', async ({ page }) => {
|
||||
await page.goto('/anmelden')
|
||||
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await page.getByLabel('Passwort').fill(PASSWORT)
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat.
|
||||
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
||||
|
||||
await page.getByTitle('Abmelden').click()
|
||||
await expect(page).toHaveURL(/\/anmelden/)
|
||||
})
|
||||
|
||||
test('zeigt der Verwaltung das Protokoll', async ({ page }) => {
|
||||
await page.goto('/anmelden')
|
||||
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await page.getByLabel('Passwort').fill(PASSWORT)
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Protokoll' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('der Health-Endpunkt antwortet ohne Anmeldung', async ({ request }) => {
|
||||
const antwort = await request.get('/api/health')
|
||||
expect(antwort.ok()).toBe(true)
|
||||
|
||||
const inhalt = (await antwort.json()) as Record<string, unknown>
|
||||
expect(inhalt.status).toBe('ok')
|
||||
expect(inhalt.datenbank).toBe('ok')
|
||||
expect(typeof inhalt.version).toBe('string')
|
||||
})
|
||||
@@ -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,74 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
|
||||
/**
|
||||
* Prüft, dass die Rollentrennung serverseitig hält.
|
||||
*
|
||||
* Die Oberfläche blendet Knöpfe aus — darauf allein darf man sich nicht
|
||||
* verlassen. Deshalb ruft dieser Test die Verwaltungsseite direkt auf, statt
|
||||
* nach einem Menüpunkt zu suchen.
|
||||
*/
|
||||
|
||||
const EMAIL = 'zzz-e2e-benutzer@example.invalid'
|
||||
const PASSWORT = 'nur-fuer-den-test-1234'
|
||||
|
||||
const verbindung = process.env.DATABASE_URL ?? ''
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||
|
||||
test.describe('Rollentrennung', () => {
|
||||
test.skip(!verbindung, 'DATABASE_URL ist nicht gesetzt.')
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await db.benutzer.deleteMany({ where: { email: EMAIL } })
|
||||
await db.benutzer.create({
|
||||
data: {
|
||||
email: EMAIL,
|
||||
name: 'Testbenutzer ohne Rechte',
|
||||
rolle: 'BENUTZER',
|
||||
passwortHash: await bcrypt.hash(PASSWORT, 10),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: EMAIL } })
|
||||
await db.benutzer.deleteMany({ where: { email: EMAIL } })
|
||||
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('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
|
||||
page,
|
||||
}) => {
|
||||
await anmelden(page)
|
||||
await page.goto('/verwaltung/protokoll')
|
||||
|
||||
await expect(page).toHaveURL(/\/keine-berechtigung/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Dazu fehlt dir die Berechtigung/ }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await page.goto('/bereiche')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Bearbeiten' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Bereich anlegen' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planer",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@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/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
@@ -593,6 +595,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"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": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
@@ -26,7 +26,8 @@
|
||||
"db:migrieren": "prisma migrate dev",
|
||||
"db:einspielen": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
"db:studio": "prisma studio",
|
||||
"db:demo-seed": "tsx prisma/demo-seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
@@ -39,6 +40,8 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@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/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import 'dotenv/config'
|
||||
|
||||
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
||||
const BASIS = `http://127.0.0.1:${PORT}`
|
||||
|
||||
/**
|
||||
* E2E-Tests gegen eine echte, gebaute Anwendung.
|
||||
*
|
||||
* Der Testserver läuft auf einem eigenen Port, damit er einer laufenden
|
||||
* Entwicklungsinstanz nicht in die Quere kommt. Die Datenbank ist dieselbe wie
|
||||
* im Entwicklungsbetrieb – die Tests legen nur Daten mit eigenem Präfix an und
|
||||
* räumen hinter sich auf.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: BASIS,
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
url: `${BASIS}/api/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
PORT: String(PORT),
|
||||
APP_URL: BASIS,
|
||||
WEBAUTHN_ORIGIN: BASIS,
|
||||
WEBAUTHN_RP_ID: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
||||
|
||||
/**
|
||||
* Eigene Konfiguration für die Screenshot-Instanz. Sie startet keinen Server —
|
||||
* darum kümmert sich scripts/screenshots.ts über Docker Compose.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './screenshots',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${PORT}`,
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dotenv/config'
|
||||
import { defineConfig, env } from 'prisma/config'
|
||||
import { defineConfig } from 'prisma/config'
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
seed: 'tsx prisma/seed.ts',
|
||||
},
|
||||
datasource: {
|
||||
url: env('DATABASE_URL'),
|
||||
// Bewusst über process.env statt über den env()-Helfer: "prisma generate"
|
||||
// läuft auch beim Bauen des Abbilds, wo es noch gar keine Datenbank gibt.
|
||||
// env() bricht dort ab. Fehlt die URL für migrate oder studio, meldet
|
||||
// Prisma das selbst.
|
||||
url: process.env.DATABASE_URL ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Daten für die Screenshot-Instanz.
|
||||
*
|
||||
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
|
||||
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
|
||||
* DEMO_MODUS nicht ausdrücklich auf true steht.
|
||||
*
|
||||
* Aufruf: über npm run screenshots, nicht von Hand.
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
|
||||
if (process.env.DEMO_MODUS !== 'true') {
|
||||
console.error(
|
||||
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
|
||||
'sonst erfundene Daten in eine echte Datenbank schreiben.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const verbindung = process.env.DATABASE_URL
|
||||
if (!verbindung) {
|
||||
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||
|
||||
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
|
||||
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
|
||||
|
||||
const BEREICHE = [
|
||||
{
|
||||
name: 'Firma',
|
||||
farbe: '#2563eb',
|
||||
reihenfolge: 1,
|
||||
arbeitsfensterVon: 450,
|
||||
arbeitsfensterBis: 990,
|
||||
kapazitaetMo: 360,
|
||||
kapazitaetDi: 360,
|
||||
kapazitaetMi: 360,
|
||||
kapazitaetDo: 360,
|
||||
kapazitaetFr: 240,
|
||||
kapazitaetSa: 0,
|
||||
kapazitaetSo: 0,
|
||||
},
|
||||
{
|
||||
name: 'Gewerbe',
|
||||
farbe: '#7c3aed',
|
||||
reihenfolge: 2,
|
||||
arbeitsfensterVon: 1020,
|
||||
arbeitsfensterBis: 1260,
|
||||
kapazitaetMo: 90,
|
||||
kapazitaetDi: 90,
|
||||
kapazitaetMi: 90,
|
||||
kapazitaetDo: 90,
|
||||
kapazitaetFr: 60,
|
||||
kapazitaetSa: 180,
|
||||
kapazitaetSo: 0,
|
||||
},
|
||||
{
|
||||
name: 'Privat',
|
||||
farbe: '#16a34a',
|
||||
reihenfolge: 3,
|
||||
arbeitsfensterVon: 1020,
|
||||
arbeitsfensterBis: 1320,
|
||||
kapazitaetMo: 60,
|
||||
kapazitaetDi: 60,
|
||||
kapazitaetMi: 60,
|
||||
kapazitaetDo: 60,
|
||||
kapazitaetFr: 120,
|
||||
kapazitaetSa: 240,
|
||||
kapazitaetSo: 240,
|
||||
},
|
||||
{
|
||||
name: 'Homelab',
|
||||
farbe: '#ea580c',
|
||||
reihenfolge: 4,
|
||||
arbeitsfensterVon: 1140,
|
||||
arbeitsfensterBis: 1380,
|
||||
kapazitaetMo: 0,
|
||||
kapazitaetDi: 60,
|
||||
kapazitaetMi: 0,
|
||||
kapazitaetDo: 60,
|
||||
kapazitaetFr: 0,
|
||||
kapazitaetSa: 120,
|
||||
kapazitaetSo: 60,
|
||||
},
|
||||
]
|
||||
|
||||
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
|
||||
const PROTOKOLL = [
|
||||
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
|
||||
{
|
||||
aktion: 'bereich.geaendert',
|
||||
objektTyp: 'Bereich',
|
||||
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
|
||||
},
|
||||
{
|
||||
aktion: 'passkey.angelegt',
|
||||
objektTyp: 'Passkey',
|
||||
details: { bezeichnung: 'Diensthandy' },
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen
|
||||
* Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig,
|
||||
* heute und später zeigen.
|
||||
*/
|
||||
const AUFGABEN = [
|
||||
// Überfällig
|
||||
{
|
||||
titel: 'Angebot Heizungstausch nachfassen',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -3,
|
||||
prioritaet: 2,
|
||||
aufwand: 30,
|
||||
},
|
||||
{
|
||||
titel: 'Rechnung 2026-114 korrigieren',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -1,
|
||||
prioritaet: 1,
|
||||
aufwand: 20,
|
||||
},
|
||||
// Heute und morgen
|
||||
{
|
||||
titel: 'Wochenbericht an die Geschäftsführung',
|
||||
bereich: 'Firma',
|
||||
versatz: 0,
|
||||
prioritaet: 2,
|
||||
aufwand: 45,
|
||||
},
|
||||
{
|
||||
titel: 'Rückruf Frau Sonnenberg',
|
||||
bereich: 'Firma',
|
||||
versatz: 0,
|
||||
prioritaet: 1,
|
||||
aufwand: 15,
|
||||
},
|
||||
{
|
||||
titel: 'Zahnarzttermin bestätigen',
|
||||
bereich: 'Privat',
|
||||
versatz: 1,
|
||||
prioritaet: 3,
|
||||
aufwand: 10,
|
||||
},
|
||||
// Später
|
||||
{
|
||||
titel: 'Backup-Strategie für den Server überdenken',
|
||||
bereich: 'Homelab',
|
||||
versatz: 9,
|
||||
prioritaet: 4,
|
||||
aufwand: 120,
|
||||
},
|
||||
{
|
||||
titel: 'Reifen wechseln lassen',
|
||||
bereich: 'Privat',
|
||||
versatz: 14,
|
||||
prioritaet: 3,
|
||||
aufwand: 60,
|
||||
},
|
||||
// Ohne Termin
|
||||
{
|
||||
titel: 'Ablage im Keller sortieren',
|
||||
bereich: 'Privat',
|
||||
versatz: null,
|
||||
prioritaet: 5,
|
||||
aufwand: 180,
|
||||
},
|
||||
// Erledigt
|
||||
{
|
||||
titel: 'Steuerunterlagen zusammenstellen',
|
||||
bereich: 'Gewerbe',
|
||||
versatz: -5,
|
||||
prioritaet: 3,
|
||||
aufwand: 90,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
]
|
||||
|
||||
/** Liegt noch in der Inbox – ohne Bereich. */
|
||||
const INBOX = [
|
||||
{ titel: 'Sicherungskasten beschriften' },
|
||||
{ titel: 'Angebot Netzwerkschrank einholen' },
|
||||
]
|
||||
|
||||
const IDEEN = [
|
||||
{ titel: 'Newsletter für Bestandskunden' },
|
||||
{ titel: 'Werkstatt-Podcast aufnehmen' },
|
||||
{ titel: 'Regale im Lager neu beschriften' },
|
||||
]
|
||||
|
||||
const PROJEKT_AUFGABEN = [
|
||||
{
|
||||
titel: 'Anforderungen sammeln',
|
||||
typ: 'ARBEITSPAKET' as const,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
{
|
||||
titel: 'Entwurf abstimmen',
|
||||
typ: 'ARBEITSPAKET' as const,
|
||||
status: 'ERLEDIGT' as const,
|
||||
},
|
||||
{ titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const },
|
||||
{ titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const },
|
||||
{ titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const },
|
||||
]
|
||||
|
||||
/** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */
|
||||
const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z')
|
||||
|
||||
function tageVersetzt(tage: number): Date {
|
||||
return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Demo-Daten einspielen.')
|
||||
|
||||
for (const bereich of BEREICHE) {
|
||||
await db.bereich.upsert({
|
||||
where: { name: bereich.name },
|
||||
update: bereich,
|
||||
create: bereich,
|
||||
})
|
||||
}
|
||||
|
||||
const benutzer = await db.benutzer.upsert({
|
||||
where: { email: DEMO_EMAIL },
|
||||
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
|
||||
create: {
|
||||
email: DEMO_EMAIL,
|
||||
name: 'Martina Beispiel',
|
||||
rolle: 'ADMIN',
|
||||
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditEintrag.deleteMany({})
|
||||
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
|
||||
let versatz = 0
|
||||
for (const eintrag of PROTOKOLL) {
|
||||
versatz += 37
|
||||
await db.auditEintrag.create({
|
||||
data: {
|
||||
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
|
||||
benutzerId: benutzer.id,
|
||||
benutzerEmail: benutzer.email,
|
||||
aktion: eintrag.aktion,
|
||||
objektTyp: eintrag.objektTyp,
|
||||
details: eintrag.details,
|
||||
instanz: 'daheim',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
|
||||
await db.passkey.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
credentialId: 'demo-passkey-diensthandy',
|
||||
publicKey: Buffer.from([0]),
|
||||
bezeichnung: 'Diensthandy',
|
||||
gesichert: true,
|
||||
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
|
||||
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
|
||||
},
|
||||
})
|
||||
|
||||
// --- 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}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
.catch(async (fehler: unknown) => {
|
||||
console.error(fehler)
|
||||
await db.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "esm"
|
||||
}
|
||||
|
||||
@@ -18,22 +18,23 @@ enum Rolle {
|
||||
|
||||
/// Ein Mensch, der sich anmelden darf.
|
||||
model Benutzer {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
|
||||
passwortHash String?
|
||||
rolle Rolle @default(BENUTZER)
|
||||
aktiv Boolean @default(true)
|
||||
bildUrl String?
|
||||
emailGeprueft DateTime?
|
||||
zuletztAktiv DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
passwortHash String?
|
||||
rolle Rolle @default(BENUTZER)
|
||||
aktiv Boolean @default(true)
|
||||
bildUrl String?
|
||||
emailGeprueft DateTime?
|
||||
zuletztAktiv DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
kalenderkonten Kalenderkonto[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("benutzer")
|
||||
@@ -63,19 +64,19 @@ model Konto {
|
||||
|
||||
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
/// Base64URL der Credential-ID.
|
||||
credentialId String @unique
|
||||
publicKey Bytes
|
||||
counter BigInt @default(0)
|
||||
credentialId String @unique
|
||||
publicKey Bytes
|
||||
counter BigInt @default(0)
|
||||
/// Kommagetrennt, z. B. "internal,hybrid".
|
||||
transports String?
|
||||
geraetetyp String?
|
||||
gesichert Boolean @default(false)
|
||||
bezeichnung String
|
||||
erstelltAm DateTime @default(now())
|
||||
zuletztBenutzt DateTime?
|
||||
transports String?
|
||||
geraetetyp String?
|
||||
gesichert Boolean @default(false)
|
||||
bezeichnung String
|
||||
erstelltAm DateTime @default(now())
|
||||
zuletztBenutzt DateTime?
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -98,12 +99,12 @@ model WebauthnChallenge {
|
||||
|
||||
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
|
||||
model Bereich {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
|
||||
farbe String
|
||||
reihenfolge Int @default(0)
|
||||
aktiv Boolean @default(true)
|
||||
farbe String
|
||||
reihenfolge Int @default(0)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
|
||||
kapazitaetMo Int @default(0)
|
||||
@@ -121,23 +122,134 @@ model Bereich {
|
||||
erstelltAm DateTime @default(now())
|
||||
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])
|
||||
@@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.
|
||||
model AuditEintrag {
|
||||
id String @id @default(cuid())
|
||||
zeitpunkt DateTime @default(now())
|
||||
benutzerId String?
|
||||
id String @id @default(cuid())
|
||||
zeitpunkt DateTime @default(now())
|
||||
benutzerId String?
|
||||
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
|
||||
benutzerEmail String
|
||||
aktion String
|
||||
objektTyp String
|
||||
objektId String?
|
||||
aktion String
|
||||
objektTyp String
|
||||
objektId String?
|
||||
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
|
||||
details Json?
|
||||
instanz String
|
||||
details Json?
|
||||
instanz String
|
||||
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -146,3 +258,123 @@ model AuditEintrag {
|
||||
@@index([benutzerId])
|
||||
@@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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { test, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Nimmt die Bilder für das Handbuch auf.
|
||||
*
|
||||
* Je Seite vier Bilder: hell und dunkel, Schreibtisch und Handy. Die Uhr steht
|
||||
* auf einem festen Zeitpunkt, damit sich zwischen zwei Läufen nur das ändert,
|
||||
* was sich wirklich geändert hat.
|
||||
*/
|
||||
|
||||
const ZIEL = 'docs/bilder'
|
||||
const SCHREIBTISCH = { width: 1440, height: 900 }
|
||||
const HANDY = { width: 390, height: 844 }
|
||||
|
||||
// Dienstag, 17. März 2026, 09:20 Uhr in Berlin.
|
||||
const FESTE_ZEIT = new Date('2026-03-17T08:20:00.000Z')
|
||||
|
||||
const EMAIL = 'martina.beispiel@planer.invalid'
|
||||
const PASSWORT = 'demo-instanz-nur-fuer-bilder'
|
||||
|
||||
type Aufnahme = {
|
||||
name: string
|
||||
pfad: string
|
||||
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
||||
vorbereiten?: (seite: Page) => Promise<void>
|
||||
}
|
||||
|
||||
const AUFNAHMEN: Aufnahme[] = [
|
||||
{ name: 'anmelden', pfad: '/anmelden' },
|
||||
{ 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: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||
]
|
||||
|
||||
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 seite.waitForURL('/')
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await mkdir(ZIEL, { recursive: true })
|
||||
})
|
||||
|
||||
for (const schema of ['hell', 'dunkel'] as const) {
|
||||
for (const [geraet, groesse] of [
|
||||
['schreibtisch', SCHREIBTISCH],
|
||||
['handy', HANDY],
|
||||
] as const) {
|
||||
test(`Bilder: ${schema}, ${geraet}`, async ({ browser }) => {
|
||||
const kontext = await browser.newContext({
|
||||
viewport: groesse,
|
||||
colorScheme: schema === 'dunkel' ? 'dark' : 'light',
|
||||
deviceScaleFactor: 2,
|
||||
})
|
||||
const seite = await kontext.newPage()
|
||||
await seite.clock.setFixedTime(FESTE_ZEIT)
|
||||
|
||||
for (const aufnahme of AUFNAHMEN) {
|
||||
if (aufnahme.pfad !== '/anmelden') {
|
||||
// Für alles außer der Anmeldeseite braucht es eine Sitzung.
|
||||
if (
|
||||
!(await seite.context().cookies()).some((k) => k.name.includes('session'))
|
||||
) {
|
||||
await anmelden(seite)
|
||||
}
|
||||
}
|
||||
|
||||
await seite.goto(aufnahme.pfad)
|
||||
await seite.waitForLoadState('networkidle')
|
||||
await aufnahme.vorbereiten?.(seite)
|
||||
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
|
||||
await seite.waitForTimeout(300)
|
||||
|
||||
await seite.screenshot({
|
||||
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
}
|
||||
|
||||
await kontext.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Integrationstest gegen eine echte Datenbank.
|
||||
*
|
||||
* Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden
|
||||
* und dass das Audit-Protokoll den Benutzer überlebt.
|
||||
*
|
||||
* Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle,
|
||||
* die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die
|
||||
* ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung
|
||||
* und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht.
|
||||
*
|
||||
* Aufruf: npm run test:integration
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
|
||||
const verbindung = process.env.DATABASE_URL
|
||||
if (!verbindung) {
|
||||
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||
|
||||
type Pruefung = { name: string; ok: boolean; hinweis?: string }
|
||||
const ergebnisse: Pruefung[] = []
|
||||
|
||||
function pruefe(name: string, bedingung: boolean, hinweis?: string): void {
|
||||
ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung })
|
||||
}
|
||||
|
||||
/** Erwartet, dass die Aktion scheitert. */
|
||||
async function scheitert(name: string, tun: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await tun()
|
||||
pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.')
|
||||
} catch {
|
||||
pruefe(name, true)
|
||||
}
|
||||
}
|
||||
|
||||
const kennung = `zzz-test-${Date.now()}`
|
||||
const testEmail = `${kennung}@example.invalid`
|
||||
|
||||
async function aufraeumen(): Promise<void> {
|
||||
// Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise
|
||||
// stehen, die den Bereich am Löschen hindern.
|
||||
await db.aufgabe.deleteMany({ where: { titel: { startsWith: kennung } } })
|
||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
||||
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||
// 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.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// --- Benutzer und Eindeutigkeit der Adresse ------------------------------
|
||||
const benutzer = await db.benutzer.create({
|
||||
data: { email: testEmail, name: 'Testkonto' },
|
||||
})
|
||||
pruefe('Benutzer anlegen', benutzer.id.length > 0)
|
||||
pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER')
|
||||
pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true)
|
||||
|
||||
await scheitert('E-Mail-Adresse ist eindeutig', () =>
|
||||
db.benutzer.create({ data: { email: testEmail } }),
|
||||
)
|
||||
|
||||
// --- Bereich und Eindeutigkeit des Namens --------------------------------
|
||||
const bereich = await db.bereich.create({
|
||||
data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 },
|
||||
})
|
||||
pruefe('Bereich anlegen', bereich.name.endsWith('-bereich'))
|
||||
pruefe(
|
||||
'Arbeitsfenster hat brauchbare Standardwerte',
|
||||
bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020,
|
||||
)
|
||||
|
||||
await scheitert('Bereichsname ist eindeutig', () =>
|
||||
db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }),
|
||||
)
|
||||
|
||||
// --- Passkey, Eindeutigkeit und Fremdschlüssel ---------------------------
|
||||
const passkey = await db.passkey.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
credentialId: `${kennung}-cred`,
|
||||
publicKey: Buffer.from([1, 2, 3]),
|
||||
bezeichnung: 'Testgerät',
|
||||
},
|
||||
})
|
||||
pruefe('Passkey anlegen', passkey.counter === 0n)
|
||||
pruefe(
|
||||
'Zähler ist ein BigInt und beginnt bei null',
|
||||
typeof passkey.counter === 'bigint',
|
||||
)
|
||||
|
||||
await scheitert('Credential-ID ist eindeutig', () =>
|
||||
db.passkey.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
credentialId: passkey.credentialId,
|
||||
publicKey: Buffer.from([4]),
|
||||
bezeichnung: 'Zweitgerät',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () =>
|
||||
db.passkey.create({
|
||||
data: {
|
||||
benutzerId: 'gibt-es-nicht',
|
||||
credentialId: `${kennung}-waise`,
|
||||
publicKey: Buffer.from([9]),
|
||||
bezeichnung: 'Waise',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// --- Öffentlicher Schlüssel kommt unverändert zurück ---------------------
|
||||
const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } })
|
||||
pruefe(
|
||||
'Der öffentliche Schlüssel kommt Byte für Byte zurück',
|
||||
Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])),
|
||||
)
|
||||
|
||||
// --- Konto und Eindeutigkeit je Anbieter ---------------------------------
|
||||
await db.konto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
type: 'oidc',
|
||||
provider: 'oidc',
|
||||
providerAccountId: kennung,
|
||||
},
|
||||
})
|
||||
await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () =>
|
||||
db.konto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
type: 'oidc',
|
||||
provider: 'oidc',
|
||||
providerAccountId: kennung,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// --- 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 ----------------------------------------
|
||||
const audit = await db.auditEintrag.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
benutzerEmail: benutzer.email,
|
||||
aktion: 'test.durchgefuehrt',
|
||||
objektTyp: 'Bereich',
|
||||
objektId: bereich.id,
|
||||
details: { name: bereich.name },
|
||||
instanz: 'daheim',
|
||||
},
|
||||
})
|
||||
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
|
||||
|
||||
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||
|
||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||
pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null)
|
||||
pruefe(
|
||||
'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen',
|
||||
nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail,
|
||||
)
|
||||
|
||||
pruefe(
|
||||
'Passkeys verschwinden mit dem Benutzer',
|
||||
(await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||
(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
|
||||
|
||||
main()
|
||||
.catch((fehler: unknown) => {
|
||||
abbruch = fehler
|
||||
})
|
||||
.finally(async () => {
|
||||
await aufraeumen()
|
||||
|
||||
const uebrig =
|
||||
(await db.benutzer.count({ where: { email: testEmail } })) +
|
||||
(await db.bereich.count({ where: { name: { startsWith: kennung } } }))
|
||||
pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0)
|
||||
|
||||
await db.$disconnect()
|
||||
|
||||
if (abbruch) {
|
||||
console.error('Der Integrationstest ist abgebrochen:', abbruch)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const gefallen = ergebnisse.filter((e) => !e.ok)
|
||||
for (const e of ergebnisse) {
|
||||
console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`)
|
||||
}
|
||||
console.log(
|
||||
`\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`,
|
||||
)
|
||||
process.exit(gefallen.length === 0 ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Migrationstest.
|
||||
*
|
||||
* Legt eine Wegwerf-Datenbank an, spielt alle Migrationen der Reihe nach ein
|
||||
* und prüft anschließend, ob das Ergebnis genau dem Schema entspricht. Damit
|
||||
* fällt auf, wenn jemand das Schema ändert, ohne eine Migration zu erzeugen —
|
||||
* beim Aufspielen im Betrieb wäre das sonst erst spät zu merken.
|
||||
*
|
||||
* Aufruf: npm run test:migration
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { Client } from 'pg'
|
||||
|
||||
const WURZEL = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
||||
|
||||
const verbindung = process.env.DATABASE_URL
|
||||
if (!verbindung) {
|
||||
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const testDatenbank = `planer_migrationstest_${Date.now()}`
|
||||
|
||||
/** Dieselbe Verbindung, aber auf eine andere Datenbank gerichtet. */
|
||||
function mitDatenbank(url: string, name: string): string {
|
||||
const zerlegt = new URL(url)
|
||||
zerlegt.pathname = `/${name}`
|
||||
return zerlegt.toString()
|
||||
}
|
||||
|
||||
function prisma(argumente: string[], zusatz: Record<string, string>): number {
|
||||
const ergebnis = spawnSync(
|
||||
process.execPath,
|
||||
[join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js'), ...argumente],
|
||||
{
|
||||
cwd: WURZEL,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...zusatz },
|
||||
},
|
||||
)
|
||||
return ergebnis.status ?? 1
|
||||
}
|
||||
|
||||
async function aufVerwaltungsdatenbank<T>(
|
||||
tun: (klient: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// "postgres" ist auf jedem Server vorhanden und wird hier nur benutzt, um
|
||||
// die Wegwerf-Datenbank anzulegen und wieder zu entfernen.
|
||||
const klient = new Client({ connectionString: mitDatenbank(verbindung!, 'postgres') })
|
||||
await klient.connect()
|
||||
try {
|
||||
return await tun(klient)
|
||||
} finally {
|
||||
await klient.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} anlegen`)
|
||||
await aufVerwaltungsdatenbank((k) => k.query(`CREATE DATABASE "${testDatenbank}"`))
|
||||
|
||||
const testUrl = mitDatenbank(verbindung!, testDatenbank)
|
||||
|
||||
console.log('▸ Alle Migrationen einspielen')
|
||||
if (prisma(['migrate', 'deploy'], { DATABASE_URL: testUrl }) !== 0) {
|
||||
console.error('✗ Die Migrationen ließen sich nicht einspielen.')
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log('▸ Ergebnis mit dem Schema vergleichen')
|
||||
// --exit-code liefert 2, wenn ein Unterschied bleibt.
|
||||
const abweichung = prisma(
|
||||
[
|
||||
'migrate',
|
||||
'diff',
|
||||
// Die Quelle ist die eben eingespielte Wegwerf-Datenbank, das Ziel das
|
||||
// Schema. So braucht der Vergleich keine Schattendatenbank.
|
||||
'--from-config-datasource',
|
||||
'--to-schema',
|
||||
'prisma/schema.prisma',
|
||||
'--exit-code',
|
||||
],
|
||||
{ DATABASE_URL: testUrl },
|
||||
)
|
||||
|
||||
if (abweichung === 2) {
|
||||
console.error(
|
||||
'\n✗ Nach dem Einspielen weicht die Datenbank vom Schema ab.\n' +
|
||||
' Vermutlich fehlt eine Migration: "npx prisma migrate dev --name <name>".',
|
||||
)
|
||||
return 1
|
||||
}
|
||||
if (abweichung !== 0) {
|
||||
console.error('✗ Der Vergleich ließ sich nicht durchführen.')
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log('\n✓ Die Migrationen ergeben genau das Schema.')
|
||||
return 0
|
||||
}
|
||||
|
||||
let code = 1
|
||||
try {
|
||||
code = await main()
|
||||
} catch (fehler) {
|
||||
console.error('Der Migrationstest ist abgebrochen:', fehler)
|
||||
} finally {
|
||||
await aufVerwaltungsdatenbank(async (k) => {
|
||||
// Offene Verbindungen trennen, sonst lässt sich die Datenbank nicht löschen.
|
||||
await k.query(
|
||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1',
|
||||
[testDatenbank],
|
||||
)
|
||||
await k.query(`DROP DATABASE IF EXISTS "${testDatenbank}"`)
|
||||
})
|
||||
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} entfernt`)
|
||||
}
|
||||
|
||||
process.exit(code)
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setzt eine neue Version: package.json, CHANGELOG, Commit und Tag.
|
||||
# Pusht bewusst nicht – das geschieht erst nach ausdrücklicher Freigabe.
|
||||
set -euo pipefail
|
||||
|
||||
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$WURZEL"
|
||||
|
||||
meldung() { printf '▸ %s\n' "$1"; }
|
||||
fehler() {
|
||||
printf 'Fehler: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
hilfe() {
|
||||
cat <<'ENDE'
|
||||
Verwendung: scripts/release.sh <version>
|
||||
|
||||
<version> Semantische Version ohne führendes v, z. B. 0.1.0
|
||||
|
||||
Ablauf:
|
||||
1. Sauberen Arbeitsbaum und grüne Prüfungen sicherstellen
|
||||
2. Version in package.json setzen
|
||||
3. "Unveröffentlicht" im CHANGELOG in einen datierten Abschnitt überführen
|
||||
4. chore(release): <version> committen und Tag v<version> anlegen
|
||||
|
||||
Es wird nicht gepusht.
|
||||
ENDE
|
||||
}
|
||||
|
||||
VERSION="${1:-}"
|
||||
case "$VERSION" in
|
||||
''|--hilfe|-h) hilfe; [ -z "$VERSION" ] && exit 1; exit 0 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|
||||
|| fehler "\"$VERSION\" sieht nicht nach einer semantischen Version aus (z. B. 0.1.0)."
|
||||
|
||||
TAG="v$VERSION"
|
||||
|
||||
# --- 1. Voraussetzungen ----------------------------------------------------
|
||||
meldung 'Arbeitsbaum prüfen.'
|
||||
[ -d .git ] || fehler 'Kein Git-Arbeitsbaum.'
|
||||
[ -z "$(git status --porcelain)" ] || fehler 'Der Arbeitsbaum ist nicht sauber.'
|
||||
|
||||
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \
|
||||
&& fehler "Den Tag $TAG gibt es schon."
|
||||
|
||||
[ -f CHANGELOG.md ] || fehler 'CHANGELOG.md fehlt.'
|
||||
grep -q '^## \[Unveröffentlicht\]' CHANGELOG.md \
|
||||
|| fehler 'Im CHANGELOG fehlt der Abschnitt "## [Unveröffentlicht]".'
|
||||
|
||||
INHALT="$(awk '
|
||||
/^## \[Unveröffentlicht\]/ { sammeln = 1; next }
|
||||
sammeln && /^## / { exit }
|
||||
sammeln { print }
|
||||
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
|
||||
[ -n "$INHALT" ] || fehler 'Der Abschnitt "Unveröffentlicht" ist leer.'
|
||||
|
||||
meldung 'Prüfungen laufen lassen.'
|
||||
npm run pruefen || fehler 'Die Prüfungen sind nicht grün.'
|
||||
|
||||
# --- 2. Version setzen -----------------------------------------------------
|
||||
meldung "Version auf $VERSION setzen."
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
|
||||
|
||||
# --- 3. CHANGELOG ----------------------------------------------------------
|
||||
meldung 'CHANGELOG fortschreiben.'
|
||||
HEUTE="$(date '+%Y-%m-%d')"
|
||||
TEMP="$(mktemp)"
|
||||
awk -v version="$VERSION" -v heute="$HEUTE" '
|
||||
/^## \[Unveröffentlicht\]/ {
|
||||
print "## [Unveröffentlicht]"
|
||||
print ""
|
||||
print "## [" version "] – " heute
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' CHANGELOG.md > "$TEMP"
|
||||
mv "$TEMP" CHANGELOG.md
|
||||
|
||||
npx prettier --write package.json CHANGELOG.md >/dev/null 2>&1 || true
|
||||
|
||||
# --- 4. Commit und Tag -----------------------------------------------------
|
||||
meldung "Commit und Tag $TAG anlegen."
|
||||
git add package.json package-lock.json CHANGELOG.md
|
||||
git commit -m "chore(release): $VERSION"
|
||||
git tag -a "$TAG" -m "Planer $VERSION"
|
||||
|
||||
meldung "Fertig. $TAG ist angelegt, aber nicht gepusht."
|
||||
printf '\nNach der Freigabe:\n'
|
||||
printf ' git push origin %s\n' "$(git rev-parse --abbrev-ref HEAD)"
|
||||
printf ' git push origin %s\n' "$TAG"
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Nimmt die Bilder für das Handbuch auf.
|
||||
*
|
||||
* Startet eine Wegwerf-Instanz in einem eigenen Compose-Projekt mit eigener
|
||||
* Datenbank, spielt ausschließlich erfundene Daten ein, fährt die Seiten mit
|
||||
* Playwright ab und räumt am Ende alles wieder weg — auch wenn unterwegs etwas
|
||||
* schiefgeht.
|
||||
*
|
||||
* An eine Instanz mit echten Daten kommt dieser Ablauf nicht heran: die
|
||||
* Datenbank entsteht neu, wird nur über ihren eigenen Port angesprochen und
|
||||
* samt Volume wieder entfernt.
|
||||
*
|
||||
* Aufruf: npm run screenshots
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||
const PROJEKT = 'planer-screenshots'
|
||||
const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
|
||||
|
||||
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
||||
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
|
||||
|
||||
const UMGEBUNG = {
|
||||
...process.env,
|
||||
SCREENSHOT_APP_PORT: APP_PORT,
|
||||
SCREENSHOT_DB_PORT: DB_PORT,
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
console.log(`▸ ${text}`)
|
||||
}
|
||||
|
||||
function laufen(
|
||||
befehl: string,
|
||||
argumente: string[],
|
||||
zusatz: Record<string, string> = {},
|
||||
): number {
|
||||
const ergebnis = spawnSync(befehl, argumente, {
|
||||
cwd: WURZEL,
|
||||
stdio: 'inherit',
|
||||
env: { ...UMGEBUNG, ...zusatz },
|
||||
})
|
||||
return ergebnis.status ?? 1
|
||||
}
|
||||
|
||||
function compose(argumente: string[]): number {
|
||||
return laufen('docker', ['compose', '-p', PROJEKT, '-f', COMPOSE_DATEI, ...argumente])
|
||||
}
|
||||
|
||||
function node(skript: string, zusatz: Record<string, string>): number {
|
||||
return laufen(
|
||||
process.execPath,
|
||||
[`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript],
|
||||
zusatz,
|
||||
)
|
||||
}
|
||||
|
||||
async function wartenAufGesundheit(sekunden: number): Promise<boolean> {
|
||||
const ziel = `http://127.0.0.1:${APP_PORT}/api/health`
|
||||
const frist = Date.now() + sekunden * 1000
|
||||
|
||||
while (Date.now() < frist) {
|
||||
try {
|
||||
const antwort = await fetch(ziel)
|
||||
if (antwort.ok) {
|
||||
const inhalt = (await antwort.json()) as { status?: string }
|
||||
if (inhalt.status === 'ok') return true
|
||||
}
|
||||
} catch {
|
||||
// Noch nicht da, gleich noch einmal.
|
||||
}
|
||||
await new Promise((weiter) => setTimeout(weiter, 2000))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function aufraeumen(): void {
|
||||
meldung('Wegwerf-Instanz entfernen.')
|
||||
compose(['down', '-v', '--remove-orphans'])
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
if (laufen('docker', ['info']) !== 0) {
|
||||
console.error('Der Docker-Daemon läuft nicht.')
|
||||
return 1
|
||||
}
|
||||
|
||||
// Reste eines abgebrochenen Laufs zuerst wegräumen.
|
||||
compose(['down', '-v', '--remove-orphans'])
|
||||
|
||||
meldung(`Wegwerf-Instanz bauen und starten (App auf ${APP_PORT}, DB auf ${DB_PORT}).`)
|
||||
if (compose(['up', '-d', '--build']) !== 0) {
|
||||
console.error('Die Wegwerf-Instanz ließ sich nicht starten.')
|
||||
return 1
|
||||
}
|
||||
|
||||
meldung('Auf die Anwendung warten.')
|
||||
if (!(await wartenAufGesundheit(180))) {
|
||||
console.error('Die Anwendung ist nicht gesund geworden.')
|
||||
compose(['logs', '--tail', '50', 'app'])
|
||||
return 1
|
||||
}
|
||||
|
||||
meldung('Erfundene Daten einspielen.')
|
||||
const demoUrl = `postgresql://demo:demo@127.0.0.1:${DB_PORT}/demo?schema=public`
|
||||
if (node('prisma/demo-seed.ts', { DATABASE_URL: demoUrl, DEMO_MODUS: 'true' }) !== 0) {
|
||||
console.error('Der Demo-Seed ist fehlgeschlagen.')
|
||||
return 1
|
||||
}
|
||||
|
||||
meldung('Bilder aufnehmen.')
|
||||
const playwright = laufen(
|
||||
process.execPath,
|
||||
[
|
||||
`${WURZEL}node_modules/@playwright/test/cli.js`,
|
||||
'test',
|
||||
'--config',
|
||||
'playwright.screenshots.config.ts',
|
||||
],
|
||||
{ SCREENSHOT_APP_PORT: APP_PORT },
|
||||
)
|
||||
if (playwright !== 0) {
|
||||
console.error('Die Aufnahme ist fehlgeschlagen.')
|
||||
return 1
|
||||
}
|
||||
|
||||
meldung('Fertig. Die Bilder liegen in docs/bilder/.')
|
||||
return 0
|
||||
}
|
||||
|
||||
let code = 1
|
||||
try {
|
||||
code = await main()
|
||||
} catch (fehler) {
|
||||
console.error('Der Screenshot-Lauf ist abgebrochen:', fehler)
|
||||
} finally {
|
||||
aufraeumen()
|
||||
}
|
||||
|
||||
process.exit(code)
|
||||
@@ -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,7 +1,8 @@
|
||||
import { Kopfleiste } from '@/components/kopfleiste'
|
||||
import { zaehlstaende } from '@/server/abfragen/aufgaben'
|
||||
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||
import { versionAnzeige } from '@/lib/version'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { aktuelleInstanz } from '@/server/instanz'
|
||||
|
||||
/**
|
||||
@@ -14,8 +15,11 @@ export default async function AngemeldetesLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung()
|
||||
// Der Pfad kommt aus der Kopfzeile des Proxys, damit es nach der
|
||||
// Anmeldung dort weitergeht, wo man hinwollte.
|
||||
const benutzer = await verlangeAnmeldung(await angefragterPfad())
|
||||
const instanz = aktuelleInstanz()
|
||||
const zahlen = await zaehlstaende()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
@@ -24,6 +28,7 @@ export default async function AngemeldetesLayout({
|
||||
email={benutzer.email}
|
||||
rolle={benutzer.rolle}
|
||||
instanzName={INSTANZ_NAME[instanz]}
|
||||
inbox={zahlen.inbox}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
@@ -8,17 +11,39 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { wochenKapazitaet } from '@/lib/bereich'
|
||||
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||
import {
|
||||
aufgabenLaden,
|
||||
bereicheLaden,
|
||||
projekteZurAuswahlLaden,
|
||||
zaehlstaende,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const jetzt = new Date()
|
||||
const heute = jetzt()
|
||||
|
||||
const bereiche = await db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||
await Promise.all([
|
||||
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
|
||||
|
||||
@@ -26,13 +51,56 @@ export default async function StartSeite() {
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Schnell erfassen</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Landet in der{' '}
|
||||
<Link href="/inbox" className="text-betont underline underline-offset-4">
|
||||
Inbox
|
||||
</Link>{' '}
|
||||
und wird später einsortiert.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<Schnellerfassung />
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
|
||||
{bereiche.length === 0 ? (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-lg font-medium">Jetzt dran</h2>
|
||||
<Link
|
||||
href="/aufgaben"
|
||||
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
|
||||
>
|
||||
Alle {zahlen.offen} offenen Aufgaben
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AufgabenListe
|
||||
aufgaben={dran}
|
||||
bereiche={bereiche}
|
||||
projekte={projekte}
|
||||
leerText="Nichts überfällig, nichts heute fällig. Ein guter Tag."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||
<Link
|
||||
href="/bereiche"
|
||||
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
|
||||
>
|
||||
Bereiche verwalten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{bereichsListe.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||
Noch keine Bereiche angelegt.{' '}
|
||||
@@ -43,38 +111,70 @@ export default async function StartSeite() {
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereiche.map((bereich) => (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
{bereichsListe.map((bereich) => {
|
||||
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
|
||||
return (
|
||||
<li key={bereich.id}>
|
||||
<Card className="h-full">
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt className="flex flex-col gap-2">
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{offeneHier.length === 0
|
||||
? '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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
</Card>
|
||||
{zahlen.ideen > 0 && (
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">
|
||||
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
|
||||
{zahlen.ideen} Idee(n) warten
|
||||
</Link>
|
||||
</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe'
|
||||
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
|
||||
import { alsDatum, alsDauer, alsTagesschluessel } from '@/lib/zeit'
|
||||
import {
|
||||
aufgabenLaden,
|
||||
bereicheLaden,
|
||||
projekteZurAuswahlLaden,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const projekt = await db.projekt.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
})
|
||||
return { title: projekt?.name ?? 'Projekt' }
|
||||
}
|
||||
|
||||
export default async function ProjektSeite({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
await verlangeAnmeldung(`/projekte/${id}`)
|
||||
|
||||
const projekt = await db.projekt.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
status: true,
|
||||
zielAm: true,
|
||||
bereichId: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
},
|
||||
})
|
||||
if (!projekt) notFound()
|
||||
|
||||
const [aufgaben, bereiche, projekte] = await Promise.all([
|
||||
// Hier bewusst ohne nurOffen: im Projekt gehört auch das Erledigte ins Bild.
|
||||
aufgabenLaden({ projektId: projekt.id }),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
])
|
||||
|
||||
const fortschritt = fortschrittBerechnen(aufgaben)
|
||||
const offenerAufwand = aufwandSumme(
|
||||
aufgaben.filter((a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN'),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
href="/projekte"
|
||||
className="text-gedaempft-vordergrund hover:text-vordergrund inline-flex w-fit items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Alle Projekte
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projekt.bereich && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: projekt.bereich.farbe }}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{projekt.name}</h1>
|
||||
{projekt.status !== 'AKTIV' && (
|
||||
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projekt.beschreibung && (
|
||||
<p className="text-gedaempft-vordergrund text-sm whitespace-pre-line">
|
||||
{projekt.beschreibung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Fortschrittsbalken
|
||||
prozent={fortschritt.prozent}
|
||||
beschriftung={`Fortschritt von ${projekt.name}`}
|
||||
/>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{fortschritt.gesamt === 0
|
||||
? 'Noch keine Aufgaben zugeordnet.'
|
||||
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
|
||||
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)} geschätzt`}
|
||||
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-medium">Aufgaben im Projekt</h2>
|
||||
<NeueAufgabe
|
||||
bereiche={bereiche}
|
||||
projekte={projekte}
|
||||
vorgabe={{
|
||||
projektId: projekt.id,
|
||||
...(projekt.bereichId ? { bereichId: projekt.bereichId } : {}),
|
||||
typ: 'ARBEITSPAKET',
|
||||
}}
|
||||
beschriftung="Aufgabe hinzufügen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AufgabenListe
|
||||
aufgaben={aufgaben}
|
||||
bereiche={bereiche}
|
||||
projekte={projekte}
|
||||
leerText="Diesem Projekt ist noch keine Aufgabe zugeordnet."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Projekt bearbeiten</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<ProjektFormular
|
||||
projekt={{
|
||||
id: projekt.id,
|
||||
name: projekt.name,
|
||||
beschreibung: projekt.beschreibung,
|
||||
status: projekt.status,
|
||||
bereichId: projekt.bereichId,
|
||||
zielWert: projekt.zielAm ? alsTagesschluessel(projekt.zielAm) : '',
|
||||
}}
|
||||
bereiche={bereiche}
|
||||
/>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
|
||||
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||
import { bereicheLaden } from '@/server/abfragen/aufgaben'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const metadata: Metadata = { title: 'Projekte' }
|
||||
|
||||
export default async function ProjekteSeite() {
|
||||
await verlangeAnmeldung('/projekte')
|
||||
|
||||
const [projekte, bereiche] = await Promise.all([
|
||||
db.projekt.findMany({
|
||||
orderBy: [{ status: 'asc' }, { name: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
status: true,
|
||||
zielAm: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
aufgaben: { select: { status: true, aufwandMinuten: true } },
|
||||
},
|
||||
}),
|
||||
bereicheLaden(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Projekte</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
Eine Klammer um mehrere Aufgaben. Aufwand und Fortschritt ergeben sich aus
|
||||
dem, was drinsteckt.
|
||||
</p>
|
||||
</div>
|
||||
<ProjektFormular bereiche={bereiche} einklappbar />
|
||||
</header>
|
||||
|
||||
{projekte.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||
Noch kein Projekt angelegt.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{projekte.map((projekt) => {
|
||||
const fortschritt = fortschrittBerechnen(projekt.aufgaben)
|
||||
const offenerAufwand = aufwandSumme(
|
||||
projekt.aufgaben.filter(
|
||||
(a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN',
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<li key={projekt.id}>
|
||||
<Card className="h-full">
|
||||
<CardKopf>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projekt.bereich && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: projekt.bereich.farbe }}
|
||||
/>
|
||||
)}
|
||||
<CardTitel className="text-base">
|
||||
<Link
|
||||
href={`/projekte/${projekt.id}`}
|
||||
className="hover:underline hover:underline-offset-4"
|
||||
>
|
||||
{projekt.name}
|
||||
</Link>
|
||||
</CardTitel>
|
||||
{projekt.status !== 'AKTIV' && (
|
||||
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
|
||||
)}
|
||||
</div>
|
||||
{projekt.beschreibung && (
|
||||
<p className="text-gedaempft-vordergrund line-clamp-2 text-sm">
|
||||
{projekt.beschreibung}
|
||||
</p>
|
||||
)}
|
||||
</CardKopf>
|
||||
|
||||
<CardInhalt className="flex flex-col gap-3">
|
||||
<Fortschrittsbalken
|
||||
prozent={fortschritt.prozent}
|
||||
beschriftung={`Fortschritt von ${projekt.name}`}
|
||||
/>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{fortschritt.gesamt === 0
|
||||
? 'Noch keine Aufgaben zugeordnet.'
|
||||
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
|
||||
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)}`}
|
||||
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, Plus, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { PROJEKT_STATUS_NAME } from '@/lib/aufgabe'
|
||||
import {
|
||||
projektAendern,
|
||||
projektAnlegen,
|
||||
projektLoeschen,
|
||||
} from '@/server/aktionen/projekte'
|
||||
|
||||
export type ProjektWerte = {
|
||||
id: string
|
||||
name: string
|
||||
beschreibung: string | null
|
||||
status: 'AKTIV' | 'PAUSIERT' | 'ABGESCHLOSSEN' | 'VERWORFEN'
|
||||
bereichId: string | null
|
||||
zielWert: string
|
||||
}
|
||||
|
||||
const STATUS = ['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'] as const
|
||||
|
||||
type Eigenschaften = {
|
||||
projekt?: ProjektWerte
|
||||
bereiche: { id: string; name: string }[]
|
||||
/** Beim Anlegen: Formular erst auf Klick zeigen. */
|
||||
einklappbar?: boolean
|
||||
onFertig?: () => void
|
||||
}
|
||||
|
||||
export function ProjektFormular({
|
||||
projekt,
|
||||
bereiche,
|
||||
einklappbar = false,
|
||||
onFertig,
|
||||
}: Eigenschaften) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||
const [offen, setOffen] = useState(!einklappbar)
|
||||
const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false)
|
||||
|
||||
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault()
|
||||
const formular = ereignis.currentTarget
|
||||
const daten = new FormData(formular)
|
||||
|
||||
starte(async () => {
|
||||
const antwort = projekt
|
||||
? await projektAendern(projekt.id, daten)
|
||||
: await projektAnlegen(daten)
|
||||
|
||||
if (antwort.ok) {
|
||||
setFelder({})
|
||||
toast.success(antwort.meldung)
|
||||
if (!projekt) {
|
||||
formular.reset()
|
||||
if (einklappbar) setOffen(false)
|
||||
}
|
||||
router.refresh()
|
||||
onFertig?.()
|
||||
} else {
|
||||
setFelder(antwort.felder ?? {})
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loeschen() {
|
||||
if (!projekt) return
|
||||
if (!loeschenBestaetigt) {
|
||||
setLoeschenBestaetigt(true)
|
||||
return
|
||||
}
|
||||
const daten = new FormData()
|
||||
daten.set('id', projekt.id)
|
||||
starte(async () => {
|
||||
const antwort = await projektLoeschen(daten)
|
||||
if (antwort.ok) {
|
||||
toast.success(antwort.meldung)
|
||||
router.push('/projekte')
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (einklappbar && !offen) {
|
||||
return (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Neues Projekt
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const kennung = projekt?.id ?? 'neu'
|
||||
|
||||
const formular = (
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`name-${kennung}`}>Name</Label>
|
||||
<Input
|
||||
id={`name-${kennung}`}
|
||||
name="name"
|
||||
defaultValue={projekt?.name ?? ''}
|
||||
required
|
||||
maxLength={80}
|
||||
aria-invalid={Boolean(felder.name)}
|
||||
/>
|
||||
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`beschreibung-${kennung}`}>Beschreibung</Label>
|
||||
<Textarea
|
||||
id={`beschreibung-${kennung}`}
|
||||
name="beschreibung"
|
||||
defaultValue={projekt?.beschreibung ?? ''}
|
||||
maxLength={2000}
|
||||
placeholder="Worum geht es, und woran merkst du, dass es fertig ist?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`status-${kennung}`}>Status</Label>
|
||||
<Select
|
||||
id={`status-${kennung}`}
|
||||
name="status"
|
||||
defaultValue={projekt?.status ?? 'AKTIV'}
|
||||
>
|
||||
{STATUS.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{PROJEKT_STATUS_NAME[status]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`bereich-${kennung}`}>Bereich</Label>
|
||||
<Select
|
||||
id={`bereich-${kennung}`}
|
||||
name="bereichId"
|
||||
defaultValue={projekt?.bereichId ?? ''}
|
||||
>
|
||||
<option value="">Kein Bereich</option>
|
||||
{bereiche.map((bereich) => (
|
||||
<option key={bereich.id} value={bereich.id}>
|
||||
{bereich.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`ziel-${kennung}`}>Zieltermin</Label>
|
||||
<Input
|
||||
id={`ziel-${kennung}`}
|
||||
name="zielAm"
|
||||
type="date"
|
||||
defaultValue={projekt?.zielWert ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" disabled={laeuft}>
|
||||
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{projekt ? 'Speichern' : 'Projekt anlegen'}
|
||||
</Button>
|
||||
|
||||
{(onFertig ?? einklappbar) && (
|
||||
<Button
|
||||
type="button"
|
||||
art="leise"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
if (einklappbar) setOffen(false)
|
||||
onFertig?.()
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{projekt && (
|
||||
<Button
|
||||
type="button"
|
||||
art="gefahr"
|
||||
className="ml-auto"
|
||||
disabled={laeuft}
|
||||
onClick={loeschen}
|
||||
onBlur={() => setLoeschenBestaetigt(false)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
{loeschenBestaetigt ? 'Wirklich löschen?' : 'Löschen'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projekt && loeschenBestaetigt && (
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Die enthaltenen Aufgaben bleiben erhalten und verlieren nur ihre Zuordnung.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
|
||||
if (!einklappbar) return formular
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardKopf className="flex-row items-center justify-between">
|
||||
<CardTitel className="text-base">Neues Projekt</CardTitel>
|
||||
<Button art="leise" groesse="quadrat" onClick={() => setOffen(false)}>
|
||||
<X aria-hidden />
|
||||
<span className="sr-only">Schließen</span>
|
||||
</Button>
|
||||
</CardKopf>
|
||||
<CardInhalt>{formular}</CardInhalt>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||