Compare commits

...
9 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 c24b3d43c8 Rechtliches: Fristen ohne Monatsende-Drift, Indizes und Warten auf Attribute im Setup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:33:56 +00:00
ClaudeandClaude Opus 5.5 c8c1d84794 Rechtliches: Erstbefuellung aus der Git-Geschichte (22 Versionen, 4 Anbieter)
Website-Seiten werden per React zu HTML gerendert, Portal-HTML kommt direkt
aus Git. Eine gemeinsame Datenschutzerklaerung statt zwei Karten, weil
webklar.com/datenschutz nur auf das Portal weiterleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:31:22 +00:00
ClaudeandClaude Opus 5.5 c7c1e338a3 Rechtliches: Abloesung am selben Tag, Kartenraster auf schmalen Bildschirmen
Bei zwei gueltigen Versionen mit gleichem Beginn gilt die zuletzt angelegte;
eine am selben Tag abgeloeste Version endet nie vor ihrem Beginn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:24:18 +00:00
ClaudeandClaude Opus 5.5 67b9439145 Rechtliches: Seite mit Rechtstexten und Anbieter-Vertraegen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:20:54 +00:00
ClaudeandClaude Opus 5.5 31be2af4c3 Rechtliches: Daten-Hook fuer Rechtstexte und Anbieter-Vertraege
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:19:04 +00:00
ClaudeandClaude Opus 5.5 1212173cb4 Rechtliches: Appwrite-Setup-Skript und Konstanten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:18:31 +00:00
ClaudeandClaude Opus 5.5 0105179e21 Rechtliches: Fristen- und Versionslogik mit Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:17:13 +00:00
ClaudeandClaude Opus 5.5 701ff1a8e8 Plan: Bereich Rechtliches in WOMS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:14:45 +00:00
ClaudeandClaude Opus 5.5 f120ec2ef3 Konzept: Bereich Rechtliches in WOMS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:12:34 +00:00
20 changed files with 1785 additions and 3 deletions

No files matched your search

@@ -0,0 +1,148 @@
# WOMS „Rechtliches“ Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Interner Menüpunkt „Rechtliches“ mit versionierten Rechtstexten und Anbieter-Verträgen samt Fristen.
**Architecture:** Drei neue Appwrite-Sammlungen + ein Bucket, angelegt per idempotentem Setup-Skript. Reine
Fristen-/Versionslogik in `src/lib/legal.js` (Node-Unit-Tests), Datenzugriff in `src/hooks/useLegal.js`,
Oberfläche in `src/pages/LegalPage.jsx` + zwei Reiter-Komponenten. Erstbefüllung per Seed-Skript nach Freigabe
der Versionsliste durch den Nutzer.
**Tech Stack:** React 18, Vite 5, Appwrite JS SDK 13 (Client) / node-appwrite (Skripte), `node --test` (Node 24), Playwright (Scratchpad).
**Spec:** `docs/superpowers/specs/2026-09-30-rechtliches-design.md`
## Global Constraints
- Arbeitsort: Worktree `/home/webklar/apps/ticket.webklar.com/dev-rechtliches`, Branch `feature/rechtliches`. **Nie pushen** – jeder Push (egal welcher Branch) geht live. Push nur durch/nach OK des Nutzers: `git push origin feature/rechtliches:test`.
- `node_modules` ist in Git getrackt: immer `git add <dateien>`, nie `-A`.
- Keine neuen npm-Abhängigkeiten.
- Oberflächentexte Deutsch. Datumsangaben als `YYYY-MM-DD`-String gespeichert, angezeigt `dd.mm.yyyy`.
- Versionen und Dateien: kein Löschen (weder UI noch Rechte). Anbieter-Verträge: Löschen erlaubt.
- Fristenfarben: rot ≤ 30 Tage, orange ≤ 90 Tage bis Kündigungstermin; grau = gekündigt/beendet; „Fristen fehlen“ bei unvollständigen Angaben.
- Keine erfundenen Fristen/Preise im Seed.
- CSS: kein Bootstrap vorhanden (`.d-flex` etc. existieren nicht), vorhandene Klassen/`ui`-Komponenten nutzen.
## Review Focus
- Abgelöst-Setzen scheitert nach dem Anlegen der neuen Version (zweiter Request 401/Netz) → zwei „gültige“ Versionen; UI muss den Fehler zeigen und `currentVersion()` die mit dem jüngsten `validFrom` nehmen (Test Task 1).
- Vertrag mit Start am 31. eines Monats → Monatsende klemmen, nie in den Folgemonat überlaufen (Test Task 1).
- Kündigungstermin genau heute → zählt noch als aktuelle Periode, rot, nicht übersprungen (Test Task 1).
- Neue Version mit `validFrom` älter als die aktuelle (Nachtrag einer alten Fassung) → darf die aktuelle NICHT ablösen, wird als `superseded` mit passendem `validTo` einsortiert (Test Task 1).
- Bucket/Sammlung fehlt noch (Setup nicht gelaufen) → Seite zeigt leeren Zustand mit Hinweis statt Absturz (Test Task 5).
---
### Task 1: Fristen- und Versionslogik
**Files:**
- Create: `src/lib/legal.js`
- Create: `tests/legal.test.mjs`
- Modify: `package.json` (Script `"test": "node --test tests/"`)
**Interfaces:**
- Produces:
- `addMonthsClamped(date: string, n: number): string`
- `nextCancellationDate(contract, today: string): { state: 'active'|'anytime'|'ends'|'ended'|'cancelled'|'incomplete', noticeBy: string|null, periodEnd: string|null }`
- `deadlineTone(result, today: string): 'red'|'orange'|'normal'|'grey'|'missing'`
- `currentVersion(versions): version|null` (Status `current`, bei mehreren das jüngste `validFrom`)
- `planNewVersion(versions, input): { create: object, updates: Array<{ id: string, patch: object }> }`
- `formatDate(date: string): string` (`YYYY-MM-DD` → `dd.mm.yyyy`, leer → `–`)
- [ ] **Step 1: Failing tests** in `tests/legal.test.mjs` (`node:test`, `assert/strict`):
- `addMonthsClamped('2026-01-31', 1) === '2026-02-28'`; `('2028-01-31', 1) === '2028-02-29'`; `('2026-03-15', 12) === '2027-03-15'`.
- Start `2025-03-12`, term 12, renewal 12, notice 1 month, today `2026-09-30` → `{state:'active', periodEnd:'2027-03-12', noticeBy:'2027-02-12'}`.
- gleiche Daten, today `2027-02-12` → noticeBy `2027-02-12` (heute zählt noch); today `2027-02-13` → periodEnd `2028-03-12`, noticeBy `2028-02-12`.
- notice 30 days, periodEnd `2027-03-12` → noticeBy `2027-02-10`.
- `termMonths: 0` → `state:'anytime'`, noticeBy null.
- renewal 0, periodEnd in Zukunft → `state:'ends'`, periodEnd gesetzt; periodEnd vorbei → `'ended'`.
- `cancelledAt` gesetzt → `'cancelled'`, periodEnd = `endsAt`.
- fehlendes `startDate` oder `noticeValue` (bei term > 0) → `'incomplete'`.
- `deadlineTone`: noticeBy in 30 Tagen → `'red'`, 31 → `'orange'`, 90 → `'orange'`, 91 → `'normal'`; cancelled/ended → `'grey'`; incomplete → `'missing'`; anytime/ends → `'normal'`.
- `planNewVersion` mit aktueller v5 (validFrom `2026-05-01`), Eingabe v6 status `current` validFrom `2026-09-01` → updates `[{id:v5.$id, patch:{status:'superseded', validTo:'2026-08-31'}}]`, create.validTo null.
- Eingabe status `draft` → updates leer.
- Eingabe `current` mit validFrom `2025-01-01` (älter als v5) → create.status `'superseded'`, create.validTo = Tag vor dem nächstjüngeren validFrom (`2026-04-30`), updates leer.
- `currentVersion` mit zwei `current` → die mit jüngerem validFrom.
- [ ] **Step 2:** `npm test` → FAIL (Modul fehlt).
- [ ] **Step 3: Implementieren.** Rein, ohne Imports; Datumsrechnung in UTC auf `YYYY-MM-DD`. Algorithmus `nextCancellationDate` (active): `periodEnd = addMonthsClamped(start, term)`; solange `noticeBy(periodEnd) < today`: `periodEnd = addMonthsClamped(periodEnd, renewal)`. Notice in Monaten per `addMonthsClamped(periodEnd, -n)`, in Tagen per Tagesabzug.
- [ ] **Step 4:** `npm test` → alle PASS.
- [ ] **Step 5: Commit** `git add src/lib/legal.js tests/legal.test.mjs package.json && git commit -m "Rechtliches: Fristen- und Versionslogik mit Tests"`
### Task 2: Appwrite-Setup und Konstanten
**Files:**
- Create: `scripts/setup-legal.mjs`
- Modify: `src/lib/appwrite.js` (`Storage` exportieren, Konstanten)
**Interfaces:**
- Produces: `COLLECTIONS.LEGAL_DOCUMENTS = 'legalDocuments'`, `COLLECTIONS.LEGAL_VERSIONS = 'legalVersions'`, `COLLECTIONS.PROVIDER_CONTRACTS = 'providerContracts'`, `LEGAL_BUCKET_ID = 'legal-files'`, `storage` (Appwrite `Storage`).
- [ ] **Step 1: Skript schreiben** nach Muster `setup-woms-appwrite.mjs` (ENV `APPWRITE_API_KEY`, idempotent, vorhandene Attribute überspringen). Attribute exakt wie Spec-Abschnitt „Daten“; Datumsfelder `string(10)`, `fileIds` string-Array (size 64), `changeNote`/`note` string(5000), `status`/`costInterval`/`noticeUnit` als enum, `cost` float, Monats-/Fristzahlen integer. Rechte: `legalDocuments`/`legalVersions` = read/create/update für `users`, **kein delete**; `providerContracts` = read/create/update/delete `users`. Bucket `legal-files`: read/create `users`, kein update/delete, `maximumFileSize` 20 MB, Endungen `pdf, html, htm, png, jpg, jpeg, webp, doc, docx`. Die 7 `legalDocuments` (Keys `agb, abo-bedingungen, avv, datenschutz-website, datenschutz-portal, impressum, widerruf`, mit Titel, Live-URL, sort 1–7) als feste IDs = Key anlegen, wenn fehlend. Flag `--dry-run` gibt nur aus, was angelegt würde.
- [ ] **Step 2:** `node --check scripts/setup-legal.mjs` → OK; `node scripts/setup-legal.mjs --dry-run` ohne Key → listet Sammlungen/Bucket/Dokumente, Exit 0.
- [ ] **Step 3:** Konstanten in `appwrite.js`; `npm run build` → Exit 0.
- [ ] **Step 4: Commit.** Skript wird erst in Task 6 mit Key ausgeführt.
### Task 3: Daten-Hook
**Files:**
- Create: `src/hooks/useLegal.js`
**Interfaces:**
- Consumes: Task 1 `planNewVersion`, Task 2 Konstanten.
- Produces:
- `useLegalDocuments(): { documents, versions, loading, error, missingSetup: boolean, addVersion(documentKey, input, files: File[]), updateVersion(id, patch), refresh }`
- `useProviderContracts(): { contracts, loading, error, missingSetup, saveContract(data, files, id?), deleteContract(id), refresh }`
- `fileUrl(fileId): string` (View-URL), `fileDownloadUrl(fileId): string`
- [ ] **Step 1: Implementieren.** Listen mit `Query.limit(5000)`. 404 auf Sammlung → `missingSetup: true`, leere Listen, kein Fehler. `addVersion`: Dateien per `storage.createFile(LEGAL_BUCKET_ID, ID.unique(), file)`, dann `planNewVersion` → erst `createDocument`, dann die `updates`; scheitert ein Update, `error` = „Neue Version gespeichert, alte Version konnte nicht abgelöst werden: …“ und trotzdem `refresh()`.
- [ ] **Step 2:** `npm run build` → Exit 0. (Verhalten wird in Task 5 per Playwright geprüft.)
- [ ] **Step 3: Commit.**
### Task 4: Seite „Rechtliches“ mit beiden Reitern
**Files:**
- Create: `src/pages/LegalPage.jsx`, `src/components/legal/LegalTextsTab.jsx`, `src/components/legal/VersionForm.jsx`, `src/components/legal/ProviderContractsTab.jsx`, `src/components/legal/ContractForm.jsx`
- Modify: `src/App.jsx` (Route `/rechtliches` in `ProtectedRoute`), `src/components/Navbar.jsx` (Eintrag „Rechtliches“ mit `IconScale` nach „Finanzen“)
**Interfaces:**
- Consumes: Task 1 (`currentVersion`, `nextCancellationDate`, `deadlineTone`, `formatDate`), Task 3 Hooks.
- [ ] **Step 1: LegalTextsTab.** Pro Dokument `Card`: Titel, aktuelle Version + „gilt seit“, Link „Live-Seite“ (`target=_blank`); Entwürfe als graues `Badge` „Entwurf“. Klick klappt Versionsgeschichte auf (neu → alt nach validFrom): Nummer, `formatDate(validFrom)`–`formatDate(validTo)`, Notiz, Dateilinks (Ansehen/Herunterladen). Aktuelle Zeile grün. Button „Neue Version“ öffnet `VersionForm` (Felder: Version, Status draft/current, gültig ab, Notiz, Dateien; Pflicht: Version, gültig ab). Entwurf-Zeilen haben „Als gültig markieren“ (setzt `status current` über `planNewVersion`-Logik: Update + Ablösung). Kein Löschen-Button.
- [ ] **Step 2: ProviderContractsTab.** Tabelle/Liste sortiert: rot, orange, normal, missing, grey; innerhalb nach noticeBy. Spalten: Anbieter, Zweck, Kosten (`12,00 €/Monat`), Laufzeit, Kündigungsfrist, nächster Kündigungstermin (bzw. „jederzeit kündbar“, „endet am …“, „gekündigt zum …“, „Fristen fehlen“). Zeilenfarbe per Klasse `legal-tone-{tone}`. Detail aufklappbar mit Notiz, Dateien, „Bearbeiten“, „Als gekündigt markieren“ (fragt `endsAt`, setzt `cancelledAt` = heute), „Löschen“ mit `confirm`. `ContractForm` für Neu/Bearbeiten.
- [ ] **Step 3: LegalPage** mit `PageHeader` „Rechtliches“ und `Tabs` (`texte` „Unsere Rechtstexte“, `anbieter` „Verträge mit Anbietern“). Bei `missingSetup` `EmptyState` „Rechtliches ist noch nicht eingerichtet (Setup-Skript fehlt).“ CSS-Regeln für `legal-tone-*` in die bestehende globale CSS.
- [ ] **Step 4:** `npm run build` → Exit 0.
- [ ] **Step 5: Commit.**
### Task 5: Oberflächentest
**Files:**
- Create (Scratchpad, nicht im Repo): `legal-e2e.mjs` – Vite-Build mit `DIST=…`, Appwrite-Mock unter `https://ticket.test` (Muster `appsmoke.mjs` aus Memory „woms-ticketsystem“), Build und Browser unter `systemd-run --scope -p MemoryMax=…`.
- [ ] **Step 1: Test schreiben**, Prüfungen:
- Menü zeigt „Rechtliches“, `/rechtliches` lädt, zwei Reiter.
- AGB-Karte zeigt aktuelle „v5“, grün; Geschichte listet v5, v4 mit `validTo`.
- „Neue Version“ v6 current ab heute → Mock erhält Create v6 **und** Patch v5 `{status:'superseded', validTo:<gestern>}`; Karte zeigt v6.
- Patch-Request mit 401 gemockt → Fehlermeldung sichtbar, v6 trotzdem gelistet.
- Kein „Löschen“ bei Versionen.
- Anbieter-Reiter: Vertrag mit noticeBy in 10 Tagen hat `legal-tone-red` und steht oben; unvollständiger zeigt „Fristen fehlen“; gekündigter ist grau und unten.
- Sammlung liefert 404 → EmptyState-Hinweis, kein Absturz.
- [ ] **Step 2:** Test laufen lassen → alle PASS; bei FAIL Code in Task 3/4 korrigieren und neu committen.
### Task 6: Einrichtung und Erstbefüllung (Nutzer-Gates)
**Files:**
- Create: `scripts/seed-legal.mjs`, `scripts/legal-seed.json` (erzeugte Versionsliste)
- [ ] **Step 1: Versionsliste rekonstruieren.** Pro Text: `git log --format='%h %ad %s' --date=short origin/main -- <datei>` in `/home/webklar/apps/webklar.com/build` (`src/pages/AGB.tsx`, `Impressum.tsx`, `Datenschutz.tsx`) und `/home/webklar/apps/kundenbereich.webklar.com/build` (`public/abo-bedingungen.html` inkl. AVV-Anlage, `public/datenschutz.html`, `public/widerrufen.html`). Nur inhaltliche Änderungen werden Versionen (reine Format-/Build-Commits zusammenfassen). Ergebnis als `legal-seed.json`: `{documentKey, version, validFrom, status, changeNote, source: '<repo>@<hash>:<pfad>'}`. Nicht auf main liegende Stände (z. B. Abo-Bedingungen v4, AGB `f40e889`) als `draft`.
- [ ] **Step 2: GATE – Nutzer prüft die Liste** (Nummerierung, v. a. AGB „v6“). Erst nach OK weiter.
- [ ] **Step 3: Seed-Skript.** Liest JSON, erzeugt je Version eine Datei: Portal-HTML direkt aus `git show`; Website-TSX als lesbares HTML (Text aus JSX, eigene Mini-Umwandlung; bei Unsicherheit TSX-Quelltext als `.html` in `<pre>`). Upload in `legal-files`, Dokument in `legalVersions`. Idempotent über `source` (schon vorhanden → überspringen). Anbieter Hetzner (Server), Hetzner (Mail), Porkbun (Domains), Stripe (Zahlungen) ohne Fristen/Preise. `--dry-run`.
- [ ] **Step 4: GATE – API-Key.** Key auf dem Server suchen (nur Dateien, die bisherige Setup-Läufe nutzten); sonst Nutzer fragen. Dann `node scripts/setup-legal.mjs`, danach `node scripts/seed-legal.mjs` → Ausgabe zählt angelegte Sammlungen/Versionen.
- [ ] **Step 5: Commit** Skripte + JSON.
### Task 7: Abschluss
- [ ] **Step 1:** `npm test`, `npm run build`, Task-5-Test → grün. Deploy-nahe Prüfung: `git archive` + `docker run node:20-alpine` (`npm install; npm run build`).
- [ ] **Step 2: GATE – Push-OK des Nutzers**, dann `git push origin feature/rechtliches:test`; Live-Bundle auf „Rechtliches“ prüfen.
- [ ] **Step 3:** Memory `woms-ticketsystem` aktualisieren.
@@ -0,0 +1,72 @@
# WOMS „Rechtliches“ – Konzept
Stand 2026-09-30, freigegeben im Gespräch (Umfang A, Rolle „Archiv + Überblick“, Anbieter mit Fristen).
## Ziel
Ein interner Menüpunkt **Rechtliches** (`/rechtliches`) in WOMS, der alle Rechtstexte von WEBklar mit
lückenloser Versionsgeschichte und alle Verträge mit Anbietern samt Fristen an einer Stelle zeigt.
Nur Mitarbeiter, keine Kunden. Die Texte werden weiter auf webklar.com und im Kundenportal gepflegt;
WOMS speichert feste Kopien jeder Version (Archiv, keine Quelle).
Nicht im Umfang: Firmenunterlagen, kundenbezogene Dokumente, Mail-Erinnerungen, automatische
Übernahme neuer Versionen aus den Deploys.
## Oberfläche
Seite mit zwei Reitern.
**Unsere Rechtstexte** – je Text eine Karte: AGB, Abo-Bedingungen, AVV, Datenschutz Website,
Datenschutz Portal, Impressum, Widerrufsbelehrung/-formular. Karte zeigt aktuelle Version, „gilt seit“,
Link zur Live-Seite. Klick öffnet die Versionsgeschichte (neu → alt): Nummer, gültig von/bis,
Änderungsnotiz, Datei ansehen/herunterladen. Aktuell = grün, Entwurf = graues Abzeichen.
„Neue Version“: Datei, Nummer, gültig ab, Notiz, Status. Wird eine Version gültig, bekommt die bisher
gültige automatisch `validTo` = neues `validFrom` minus 1 Tag und Status „abgelöst“.
**Verträge mit Anbietern** – Liste mit Anbieter, Zweck, Kosten, Laufzeit, Kündigungsfrist, nächster
Kündigungstermin; sortiert nach nächstem Termin. Rot ≤ 30 Tage, orange ≤ 90 Tage, grau = gekündigt/beendet,
„Fristen fehlen“ wenn Angaben unvollständig. Detailansicht mit Dateien und Notiz; Anlegen, Bearbeiten,
„Als gekündigt markieren“.
## Daten (Appwrite `woms-database`)
- `legalDocuments`: `key` (z. B. `agb`), `title`, `liveUrl`, `sort`.
- `legalVersions`: `documentKey`, `version` (Text, z. B. „v6“), `validFrom`, `validTo`,
`status` (`draft` | `current` | `superseded`), `changeNote`, `fileIds[]`.
- `providerContracts`: `provider`, `purpose`, `cost`, `costInterval` (`month` | `year`), `startDate`,
`termMonths`, `renewalMonths` (0 = keine Verlängerung), `noticeValue` + `noticeUnit` (`days` | `months`),
`cancelledAt`, `endsAt`, `note`, `fileIds[]`.
Bucket `legal-files`: PDF, HTML, Bilder, DOC/DOCX, max. 20 MB.
Rechte: `users` dürfen lesen, anlegen, bearbeiten. Kein Löschen bei Versionen und Dateien (Archiv bleibt
vollständig); Anbieter-Verträge dürfen gelöscht werden.
Anlage per Setup-Skript (`scripts/setup-legal.mjs`, idempotent wie `setup-woms-appwrite.mjs`),
braucht einmal einen Appwrite-API-Key.
## Code
- `src/lib/legal.js`: `nextCancellationDate(contract, today)`, `deadlineTone(date, today)`,
`supersedePrevious(versions, newVersion)`; reine Funktionen.
- `src/hooks/useLegal.js`: Laden/Speichern der drei Sammlungen, Datei-Upload/-Link über Appwrite Storage.
- `src/pages/LegalPage.jsx` mit den Reitern; Route und Menüpunkt in `App.jsx`/`Navbar.jsx`.
`nextCancellationDate`: Ende der laufenden Periode = Start + Laufzeit, danach je + Verlängerung, bis das
Periodenende nach heute liegt; Termin = Periodenende minus Kündigungsfrist (Monatsende korrekt geklemmt,
z. B. 31.01. + 1 Monat = 28./29.02.). Liegt der Termin schon vorbei, zählt die nächste Periode.
Ohne Verlängerung: Vertragsende, danach „beendet“. Gekündigt: kein Termin, grau. Fehlende Angaben: `null`
→ „Fristen fehlen“.
## Erstbefüllung
Skript `scripts/seed-legal.mjs`: Versionen aus der Git-Geschichte von webklar.com (`JUSN/Webklar`) und
dem Kundenportal rekonstruieren (je live gegangene Fassung eine Version, Datum = Commit auf main,
gespeichert als HTML/PDF). Die Versionsliste bekommt der Nutzer **vor dem Eintragen** zur Prüfung,
damit die Nummerierung (AGB aktuell „v6“) zu seiner passt. Nicht-live-Stände (z. B. Abo-Bedingungen v4)
als `draft`. Anbieter Hetzner (Server, Mail), Porkbun, Stripe als Einträge ohne geratene Fristen/Preise.
## Test und Auslieferung
Unit-Tests für `lib/legal.js` (Monatsende, Schaltjahr, keine Verlängerung, gekündigt, fehlende Angaben,
Ablösung). Playwright-Test gegen Vite-Build mit gemocktem Appwrite (Reiter, Karte, Versionsgeschichte,
neue Version löst alte ab, Farben der Fristen). Worktree `dev-rechtliches`, Branch `feature/rechtliches`;
live erst nach OK des Nutzers per `push … :test`.
+2 -1
View File
@@ -18,6 +18,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test tests/*.test.mjs"
}
}
+180
View File
@@ -0,0 +1,180 @@
{
"versions": [
{
"documentKey": "agb",
"version": "v1",
"validFrom": "2026-02-02",
"status": "superseded",
"changeNote": "Erste Fassung der AGB auf webklar.com",
"source": "webklar.com@217bbdc:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v2",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "Modul C Hosting ergänzt (Hosting-Abo, Wartungs-Abo mit beliebig vielen Postfächern)",
"source": "webklar.com@dc27e50:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v3",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "An die Vertragsbedingungen angeglichen (Wechsel, Zusatzspeicher, Sicherheit), Verweise auf die Abo-Bedingungen",
"source": "webklar.com@2c1a4c1:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v4",
"validFrom": "2026-09-28",
"status": "superseded",
"changeNote": "Haftungssatz in Modul C entfällt; Kündigung nach Verlängerung für Verbraucher",
"source": "webklar.com@2bffca4:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v5",
"validFrom": "2026-09-28",
"status": "current",
"changeNote": "Verbraucher-Kündigung als Ausnahme formuliert",
"source": "webklar.com@044f788:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v6",
"validFrom": "2026-09-28",
"status": "draft",
"changeNote": "§ 11 Haftung neu nach dem Muster der Abo-Bedingungen (noch nicht live)",
"source": "webklar.com@f40e889:src/pages/AGB.tsx"
},
{
"documentKey": "abo-bedingungen",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Erste Fassung: Wartungs-Abo 50 €/Monat über Stripe mit Stundenkonto",
"source": "kundenbereich@65429aa:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Vertragsunterlagen Version 2 nach Rechtsrunde, Postfächer und Domains bei Hetzner",
"source": "kundenbereich@4ab154b:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v3",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Version 3: Mindestlaufzeit 12 Monate; Kontakt support@webklar.com",
"source": "kundenbereich@dbabbf1:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "Version 4: Hosting-Abo und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel, AVV; § 13 Abs. 3 mit Ausnahme für Absatz 1; Hosting-Preis 20 €",
"source": "kundenbereich@e4efdac:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Erste AVV als Anlage der Vertragsbedingungen Version 2 (in Version 1 nur „auf Anfrage“)",
"source": "kundenbereich@4ab154b:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v3",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "AVV als Anlage der Vertragsbedingungen Version 3",
"source": "kundenbereich@dbabbf1:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "AVV als Anlage der Vertragsbedingungen Version 4 (Anlagen 1 und 3 überarbeitet)",
"source": "kundenbereich@e4efdac:public/abo-bedingungen.html"
},
{
"documentKey": "datenschutz",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Stand 25.09.2026: Abo, Übernahmegutschrift",
"source": "kundenbereich@48b13f4:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v2",
"validFrom": "2026-09-26",
"status": "superseded",
"changeNote": "Stand 26.09.2026: Chat mit Bildern, Dateien und Sprachnachrichten",
"source": "kundenbereich@35d98da:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v3",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "Stand 27.09.2026: E-Mail bei Hetzner statt Porkbun, UptimeRobot, Bestellungen im Portal",
"source": "kundenbereich@7e86928:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "Stand 30.09.2026: Livegang mit Vertragsbedingungen Version 4",
"source": "kundenbereich@78b4d7b:public/datenschutz.html"
},
{
"documentKey": "impressum",
"version": "v1",
"validFrom": "2026-02-02",
"status": "superseded",
"changeNote": "Erste Fassung",
"source": "webklar.com@6af24d2:src/pages/Impressum.tsx"
},
{
"documentKey": "impressum",
"version": "v2",
"validFrom": "2026-09-25",
"status": "current",
"changeNote": "Neu nach DDG mit Gesellschaftern; Kontakt support@webklar.com",
"source": "webklar.com@f74ddfb:src/pages/Impressum.tsx"
},
{
"documentKey": "widerruf",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Widerrufsformular im Kundenportal",
"source": "kundenbereich@65429aa:public/widerrufen.html"
},
{
"documentKey": "widerruf",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Überarbeitet nach Rechtsrunde",
"source": "kundenbereich@4ab154b:public/widerrufen.html"
},
{
"documentKey": "widerruf",
"version": "v3",
"validFrom": "2026-09-29",
"status": "current",
"changeNote": "Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)",
"source": "kundenbereich@7ffe95f:public/widerrufen.html"
}
]
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* WOMS Rechtliches – Erstbefuellung (idempotent ueber das Feld `source`).
* Liest scripts/legal-seed.json, erzeugt je Version eine Archivkopie als HTML
* (Portal-HTML direkt aus Git, Website-Seiten per React zu HTML gerendert),
* laedt sie in den Bucket legal-files und legt die Versionen an. Dazu die
* Anbieter-Vertraege ohne Fristen und Preise.
*
* Usage:
* node scripts/seed-legal.mjs --dry-run [--out DIR] (nur rendern, nichts hochladen)
* APPWRITE_API_KEY=... node scripts/seed-legal.mjs
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const APPS = "/home/webklar/apps";
const REPOS = {
"webklar.com": process.env.WEBSITE_REPO || `${APPS}/webklar.com/build`,
kundenbereich: process.env.PORTAL_REPO || `${APPS}/kundenbereich.webklar.com/build`,
};
const NODE_MODULES = process.env.WOMS_NODE_MODULES || `${APPS}/ticket.webklar.com/build/node_modules`;
const dryRun = process.argv.includes("--dry-run");
const outIdx = process.argv.indexOf("--out");
const outDir = outIdx > 0 ? process.argv[outIdx + 1] : null;
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
export const PROVIDERS = [
{ provider: "Hetzner", purpose: "Server (Hosting aller Websites und Systeme)" },
{ provider: "Hetzner", purpose: "E-Mail-Postfächer" },
{ provider: "Porkbun", purpose: "Domains" },
{ provider: "Stripe", purpose: "Zahlungen der Abos" },
];
const require = createRequire(path.join(NODE_MODULES, "noop.js"));
function gitShow(repo, hash, file) {
return execFileSync("git", ["-C", REPOS[repo], "show", `${hash}:${file}`], { maxBuffer: 20 * 1024 * 1024 });
}
// Alles ausser React wird zu einem Platzhalter: Komponenten geben nur ihre Kinder aus,
// Link wird zu <a>, Icons verschwinden. Die Exportnamen kommen aus den Imports der Seite.
function stubFor(source, specifier) {
const names = new Set();
let hasDefault = false;
const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*["']([^"']+)["']/g;
for (const m of source.matchAll(re)) {
if (m[3] !== specifier) continue;
if (m[1]) hasDefault = true;
for (const n of (m[2] || "").split(",")) {
const name = n.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, "");
if (name) names.add(name);
}
}
const lines = [
"import React from 'react';",
"const Pass = (p) => (p && p.children !== undefined ? React.createElement(React.Fragment, null, p.children) : null);",
];
for (const n of names) {
lines.push(n === "Link" || n === "NavLink"
? `export const ${n} = (p) => React.createElement('a', { href: p.to || p.href }, p.children);`
: /^[A-Z]/.test(n) ? `export const ${n} = Pass;` : `export const ${n} = () => undefined;`);
}
if (hasDefault) lines.push("export default Pass;");
return lines.join("\n");
}
async function renderTsx(source) {
const esbuild = require("esbuild");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "legal-"));
const entry = path.join(tmp, "page.tsx");
fs.writeFileSync(entry, source);
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "cjs",
platform: "node",
jsx: "automatic",
nodePaths: [NODE_MODULES],
external: ["react", "react-dom", "react/jsx-runtime"],
logLevel: "silent",
plugins: [{
name: "stub",
setup(b) {
b.onResolve({ filter: /.*/ }, (a) =>
a.kind === "entry-point" || /^react(\/jsx-runtime)?$/.test(a.path) ? undefined : { path: a.path, namespace: "stub" });
b.onLoad({ filter: /.*/, namespace: "stub" }, (a) => ({ contents: stubFor(source, a.path), loader: "js", resolveDir: NODE_MODULES }));
},
}],
});
fs.rmSync(tmp, { recursive: true, force: true });
const mod = { exports: {} };
new Function("module", "exports", "require", result.outputFiles[0].text)(mod, mod.exports, require);
const Page = mod.exports.default;
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
const html = renderToStaticMarkup(React.createElement(Page));
const main = /<main[\s\S]*<\/main>/.exec(html);
return main ? main[0] : html;
}
function wrap(title, entry, body) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} ${entry.version} – Archivkopie</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:820px;margin:0 auto;padding:24px;color:#111}
.archiv{background:#f3f4f6;border:1px solid #d1d5db;padding:8px 12px;border-radius:6px;font-size:13px;margin-bottom:24px}
svg{display:none}h1{font-size:28px}</style></head>
<body><div class="archiv">Archivkopie aus WOMS · ${title} ${entry.version} · gültig ab ${entry.validFrom} · Quelle ${entry.source}</div>
${body}
</body></html>`;
}
export async function buildFile(entry, title) {
const [repo, rest] = entry.source.split("@");
const [hash, file] = rest.split(":");
const raw = gitShow(repo, hash, file);
const name = `${entry.documentKey}-${entry.version}-${hash}.html`;
if (file.endsWith(".html")) return { name, content: raw };
const body = await renderTsx(raw.toString("utf8"));
return { name, content: Buffer.from(wrap(title, entry, body), "utf8") };
}
async function run() {
const { versions } = JSON.parse(fs.readFileSync(path.join(HERE, "legal-seed.json"), "utf8"));
const { DOCUMENTS } = await import("./setup-legal.mjs");
const titles = Object.fromEntries(DOCUMENTS.map((d) => [d.key, d.title]));
if (dryRun) {
if (outDir) fs.mkdirSync(outDir, { recursive: true });
for (const v of versions) {
const f = await buildFile(v, titles[v.documentKey]);
if (outDir) fs.writeFileSync(path.join(outDir, f.name), f.content);
console.log(`${v.documentKey} ${v.version} ${v.status} ab ${v.validFrom}: ${f.name} (${f.content.length} Bytes)`);
}
for (const p of PROVIDERS) console.log(`Anbieter: ${p.provider} – ${p.purpose}`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases, Storage, ID, Query } = await import("node-appwrite");
const { InputFile } = await import("node-appwrite/file");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const storage = new Storage(client);
let created = 0;
for (const v of versions) {
const found = await db.listDocuments(databaseId, "legalVersions", [Query.equal("source", v.source), Query.equal("documentKey", v.documentKey), Query.limit(1)]);
if (found.total > 0) {
console.log("vorhanden:", v.documentKey, v.version);
continue;
}
const f = await buildFile(v, titles[v.documentKey]);
const file = await storage.createFile("legal-files", ID.unique(), InputFile.fromBuffer(f.content, f.name));
const doc = { ...v, validTo: null, fileIds: [file.$id] };
await db.createDocument(databaseId, "legalVersions", ID.unique(), doc);
created++;
console.log("angelegt:", v.documentKey, v.version);
}
// validTo der abgeloesten Versionen: Tag vor der naechsten nicht-Entwurf-Version, nie vor dem eigenen Beginn
const all = (await db.listDocuments(databaseId, "legalVersions", [Query.limit(5000)])).documents;
for (const v of all.filter((x) => x.status === "superseded" && !x.validTo)) {
const next = all
.filter((x) => x.documentKey === v.documentKey && x.status !== "draft" && x.$id !== v.$id &&
(x.validFrom > v.validFrom || (x.validFrom === v.validFrom && x.version > v.version)))
.sort((a, b) => (a.validFrom + a.version).localeCompare(b.validFrom + b.version))[0];
if (!next) continue;
const d = new Date(`${next.validFrom}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
const before = d.toISOString().slice(0, 10);
await db.updateDocument(databaseId, "legalVersions", v.$id, { validTo: before < v.validFrom ? v.validFrom : before });
}
const existing = (await db.listDocuments(databaseId, "providerContracts", [Query.limit(5000)])).documents;
for (const p of PROVIDERS) {
if (existing.some((c) => c.provider === p.provider && c.purpose === p.purpose)) continue;
await db.createDocument(databaseId, "providerContracts", ID.unique(), { ...p, fileIds: [] });
console.log("Anbieter angelegt:", p.provider, p.purpose);
}
console.log(`Fertig: ${created} Versionen angelegt.`);
}
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* WOMS Rechtliches – Appwrite-Setup (idempotent).
* Legt die Sammlungen legalDocuments, legalVersions, providerContracts, den Bucket
* legal-files und die festen Rechtstext-Karten an. Versionen und Dateien bekommen
* bewusst keine Loeschrechte: das Archiv soll vollstaendig bleiben.
*
* Usage:
* node scripts/setup-legal.mjs --dry-run
* APPWRITE_API_KEY=... node scripts/setup-legal.mjs
*/
import { pathToFileURL } from "node:url";
const dryRun = process.argv.includes("--dry-run");
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
const READ_WRITE = ['read("users")', 'create("users")', 'update("users")'];
const CRUD = [...READ_WRITE, 'delete("users")'];
const date = (key) => ({ key, type: "string", size: 10 });
export const COLLECTIONS = [
{
id: "legalDocuments",
name: "Rechtstexte",
perms: READ_WRITE,
attrs: [
{ key: "key", type: "string", size: 64, required: true },
{ key: "title", type: "string", size: 200, required: true },
{ key: "liveUrl", type: "string", size: 500 },
{ key: "sort", type: "integer", min: 0, max: 1000 },
],
indexes: [{ key: "idx_sort", type: "key", attributes: ["sort"] }],
},
{
id: "legalVersions",
name: "Rechtstext-Versionen",
perms: READ_WRITE,
attrs: [
{ key: "documentKey", type: "string", size: 64, required: true },
{ key: "version", type: "string", size: 32, required: true },
date("validFrom"),
date("validTo"),
{ key: "status", type: "enum", elements: ["draft", "current", "superseded"], required: true },
{ key: "changeNote", type: "string", size: 5000 },
{ key: "fileIds", type: "string[]", size: 64 },
{ key: "source", type: "string", size: 300 },
],
indexes: [
{ key: "idx_documentKey", type: "key", attributes: ["documentKey"] },
{ key: "idx_source", type: "key", attributes: ["source"] },
],
},
{
id: "providerContracts",
name: "Anbieter-Verträge",
perms: CRUD,
attrs: [
{ key: "provider", type: "string", size: 200, required: true },
{ key: "purpose", type: "string", size: 300 },
{ key: "cost", type: "float" },
{ key: "costInterval", type: "enum", elements: ["month", "year"] },
date("startDate"),
{ key: "termMonths", type: "integer", min: 0, max: 1200 },
{ key: "renewalMonths", type: "integer", min: 0, max: 1200 },
{ key: "noticeValue", type: "integer", min: 0, max: 3650 },
{ key: "noticeUnit", type: "enum", elements: ["days", "months"] },
date("cancelledAt"),
date("endsAt"),
{ key: "note", type: "string", size: 5000 },
{ key: "fileIds", type: "string[]", size: 64 },
],
indexes: [{ key: "idx_provider", type: "key", attributes: ["provider"] }],
},
];
export const BUCKET = {
id: "legal-files",
name: "Rechtliches Dateien",
perms: ['read("users")', 'create("users")'],
maxSize: 20 * 1024 * 1024,
extensions: ["pdf", "html", "htm", "png", "jpg", "jpeg", "webp", "doc", "docx"],
};
export const DOCUMENTS = [
{ key: "agb", title: "AGB", liveUrl: "https://webklar.com/agb" },
{ key: "abo-bedingungen", title: "Abo-Bedingungen", liveUrl: "https://project.webklar.com/abo-bedingungen.html" },
{ key: "avv", title: "AVV (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/abo-bedingungen.html" },
{ key: "datenschutz", title: "Datenschutzerklärung (Website und Portal)", liveUrl: "https://project.webklar.com/datenschutz.html" },
{ key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" },
{ key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" },
].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Wartet, bis Appwrite alle Attribute einer Sammlung fertig angelegt hat. */
export async function waitForAttributes(db, collectionId, { intervalMs = 1000, timeoutMs = 120000 } = {}) {
const until = Date.now() + timeoutMs;
for (;;) {
const { attributes } = await db.listAttributes(databaseId, collectionId);
const failed = attributes.filter((a) => a.status === "failed" || a.status === "stuck");
if (failed.length) {
throw new Error(`Attribut fehlgeschlagen in ${collectionId}: ${failed.map((a) => `${a.key} (${a.error || a.status})`).join(", ")}`);
}
if (attributes.every((a) => a.status === "available")) return;
if (Date.now() > until) throw new Error(`Attribute in ${collectionId} nach ${timeoutMs} ms nicht verfuegbar`);
await sleep(intervalMs);
}
}
async function createAttribute(db, collectionId, a) {
const req = Boolean(a.required);
switch (a.type) {
case "integer":
return db.createIntegerAttribute(databaseId, collectionId, a.key, req, a.min, a.max);
case "float":
return db.createFloatAttribute(databaseId, collectionId, a.key, req);
case "enum":
return db.createEnumAttribute(databaseId, collectionId, a.key, a.elements, req);
case "string[]":
return db.createStringAttribute(databaseId, collectionId, a.key, a.size, req, undefined, true);
default:
return db.createStringAttribute(databaseId, collectionId, a.key, a.size, req);
}
}
async function run() {
if (dryRun) {
for (const c of COLLECTIONS) {
console.log(`Sammlung ${c.id} (${c.perms.join(", ")}): ${c.attrs.map((a) => a.key).join(", ")}`);
}
console.log(`Bucket ${BUCKET.id}: ${BUCKET.extensions.join(", ")}, max ${BUCKET.maxSize} Bytes`);
for (const d of DOCUMENTS) console.log(`Rechtstext ${d.key}: ${d.title}`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases, Storage } = await import("node-appwrite");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const storage = new Storage(client);
for (const c of COLLECTIONS) {
try {
await db.getCollection(databaseId, c.id);
await db.updateCollection(databaseId, c.id, c.name, c.perms, false, true);
console.log("Sammlung vorhanden:", c.id);
} catch (e) {
if (e.code !== 404) throw e;
await db.createCollection(databaseId, c.id, c.name, c.perms, false, true);
console.log("Sammlung angelegt:", c.id);
}
const existing = new Set((await db.listAttributes(databaseId, c.id)).attributes.map((a) => a.key));
for (const a of c.attrs) {
if (existing.has(a.key)) continue;
await createAttribute(db, c.id, a);
console.log(" Attribut", a.key);
await sleep(400);
}
await waitForAttributes(db, c.id);
const indexes = new Set((await db.listIndexes(databaseId, c.id)).indexes.map((i) => i.key));
for (const idx of c.indexes || []) {
if (indexes.has(idx.key)) continue;
await db.createIndex(databaseId, c.id, idx.key, idx.type, idx.attributes);
console.log(" Index", idx.key);
}
}
try {
await storage.getBucket(BUCKET.id);
console.log("Bucket vorhanden:", BUCKET.id);
} catch (e) {
if (e.code !== 404) throw e;
await storage.createBucket(BUCKET.id, BUCKET.name, BUCKET.perms, false, true, BUCKET.maxSize, BUCKET.extensions);
console.log("Bucket angelegt:", BUCKET.id);
}
for (const d of DOCUMENTS) {
try {
await db.getDocument(databaseId, "legalDocuments", d.key);
} catch (e) {
if (e.code !== 404) throw e;
await db.createDocument(databaseId, "legalDocuments", d.key, d);
console.log("Rechtstext angelegt:", d.key);
}
}
console.log("Fertig.");
}
// Nur direkt aufgerufen ausfuehren; seed-legal.mjs importiert DOCUMENTS.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
}
+2
View File
@@ -11,6 +11,7 @@ import CustomersPage from './pages/CustomersPage'
import LeadsPage from './pages/LeadsPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage'
import LegalPage from './pages/LegalPage'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
@@ -43,6 +44,7 @@ function AppRoutes() {
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
+3 -1
View File
@@ -6,6 +6,7 @@ import {
IconUsers,
IconTargetArrow,
IconReportMoney,
IconScale,
IconFolders,
IconCalendar,
IconSettings,
@@ -25,7 +26,7 @@ export default function Sidebar() {
navigate('/login')
}
// Eine flache Liste: fuer ein kleines Team reichen sieben Eintraege ohne Gruppen
// Eine flache Liste: fuer ein kleines Team reichen acht Eintraege ohne Gruppen
const links = [
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
@@ -33,6 +34,7 @@ export default function Sidebar() {
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> },
{ label: 'Rechtliches', href: '/rechtliches', icon: <IconScale /> },
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
]
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { Button } from '../ui'
const FIELDS = ['provider', 'purpose', 'cost', 'costInterval', 'startDate', 'termMonths', 'renewalMonths', 'noticeValue', 'noticeUnit', 'note']
const NUMBERS = ['termMonths', 'renewalMonths', 'noticeValue']
function toForm(contract) {
const form = { costInterval: 'month', noticeUnit: 'months' }
for (const key of FIELDS) {
if (contract?.[key] !== null && contract?.[key] !== undefined) form[key] = String(contract[key])
}
return form
}
/** Leere Felder werden null, damit die Fristenlogik "Fristen fehlen" erkennt. */
function toDoc(form) {
const doc = {}
for (const key of FIELDS) {
const raw = (form[key] ?? '').trim()
if (raw === '') doc[key] = null
else if (NUMBERS.includes(key)) doc[key] = parseInt(raw, 10)
else if (key === 'cost') doc[key] = parseFloat(raw.replace(',', '.'))
else doc[key] = raw
}
return doc
}
export default function ContractForm({ contract, onSave, onClose }) {
const [form, setForm] = useState(() => toForm(contract))
const [files, setFiles] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
const submit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await onSave(toDoc(form), files)
onClose()
} catch (err) {
setError(err.message)
setSaving(false)
}
}
const input = (key, label, props = {}) => (
<div className="form-group">
<label className="form-label">{label}</label>
<input className="form-control" name={key} value={form[key] || ''} onChange={set(key)} {...props} />
</div>
)
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
<div className="overlay-content">
<h2 className="mb-2">{contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}</h2>
{error && <div className="legal-error">{error}</div>}
<form onSubmit={submit}>
{input('provider', 'Anbieter', { required: true })}
{input('purpose', 'Wofür')}
<div className="legal-form-row">
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
<div className="form-group">
<label className="form-label">pro</label>
<select className="form-control" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
</div>
</div>
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
<div className="legal-form-row">
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
</div>
<div className="legal-form-row">
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
<div className="form-group">
<label className="form-label">Einheit</label>
<select className="form-control" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
<option value="months">Monate</option>
<option value="days">Tage</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Notiz</label>
<textarea className="form-control" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
</div>
<div className="form-group">
<label className="form-label">Dateien hinzufügen</label>
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</div>
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
<Button type="button" onClick={onClose}>Abbrechen</Button>
</div>
</form>
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Badge, Button, Card } from '../ui'
import { currentVersion, formatDate } from '../../lib/legal'
import VersionForm from './VersionForm'
import { FileLinks } from './legalUi'
const byNewest = (a, b) => (b.validFrom || '').localeCompare(a.validFrom || '')
export default function LegalTextsTab({ documents, versions, fileNames, addVersion, promoteDraft }) {
const [open, setOpen] = useState(null)
const [formFor, setFormFor] = useState(null)
const [actionError, setActionError] = useState(null)
const promote = async (v) => {
setActionError(null)
try {
await promoteDraft(v)
} catch (err) {
setActionError(err.message)
}
}
return (
<div className="legal-grid">
{actionError && <div className="legal-error">{actionError}</div>}
{documents.map((doc) => {
const own = versions.filter((v) => v.documentKey === doc.key).sort(byNewest)
const current = currentVersion(own)
const drafts = own.filter((v) => v.status === 'draft')
const expanded = open === doc.key
return (
<Card
key={doc.$id}
className="legal-doc"
title={doc.title}
actions={<Button size="sm" onClick={() => setFormFor(doc)}>Neue Version</Button>}
>
<div className="legal-doc-summary" onClick={() => setOpen(expanded ? null : doc.key)} role="button">
{current ? (
<Badge tone="ok">{current.version} · gilt seit {formatDate(current.validFrom)}</Badge>
) : (
<Badge tone="muted">noch keine gültige Version</Badge>
)}
{drafts.map((d) => (
<Badge key={d.$id} tone="muted">Entwurf {d.version}</Badge>
))}
<span className="faint legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
</div>
{doc.liveUrl && (
<a href={doc.liveUrl} target="_blank" rel="noreferrer" className="legal-live">Live-Seite öffnen</a>
)}
{expanded && (
<ol className="legal-history">
{own.length === 0 && <li className="faint">Noch keine Versionen eingetragen.</li>}
{own.map((v) => (
<li key={v.$id} className={`legal-version legal-version-${v.status}`}>
<div className="legal-version-head">
<strong>{v.version}</strong>
<span>{formatDate(v.validFrom)} – {v.status === 'current' ? 'heute' : formatDate(v.validTo)}</span>
{v.status === 'current' && <Badge tone="ok">gültig</Badge>}
{v.status === 'draft' && <Badge tone="muted">Entwurf</Badge>}
{v.status === 'draft' && (
<Button size="sm" onClick={() => promote(v)}>Als gültig markieren</Button>
)}
</div>
{v.changeNote && <div className="legal-note">{v.changeNote}</div>}
<FileLinks fileIds={v.fileIds} fileNames={fileNames} />
</li>
))}
</ol>
)}
</Card>
)
})}
{formFor && (
<VersionForm
title={formFor.title}
onSave={(input, files) => addVersion(formFor.key, input, files)}
onClose={() => setFormFor(null)}
/>
)}
</div>
)
}
@@ -0,0 +1,141 @@
import { Fragment, useState } from 'react'
import { Button, EmptyState } from '../ui'
import { deadlineTone, formatDate, nextCancellationDate } from '../../lib/legal'
import ContractForm from './ContractForm'
import { FileLinks, todayIso } from './legalUi'
const TONE_ORDER = { red: 0, orange: 1, normal: 2, missing: 3, grey: 4 }
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
function costLabel(c) {
if (c.cost === null || c.cost === undefined) return '–'
return `${euro.format(c.cost)}/${c.costInterval === 'year' ? 'Jahr' : 'Monat'}`
}
function termLabel(c) {
if (c.termMonths === null || c.termMonths === undefined) return '–'
if (c.termMonths === 0) return 'jederzeit kündbar'
const renew = c.renewalMonths ? `, dann je ${c.renewalMonths} Monate` : ', endet danach'
return `${c.termMonths} Monate${renew}`
}
function noticeLabel(c) {
if (c.noticeValue === null || c.noticeValue === undefined) return '–'
return `${c.noticeValue} ${c.noticeUnit === 'days' ? 'Tage' : 'Monate'}`
}
function deadlineLabel(r) {
switch (r.state) {
case 'active': return `kündbar bis ${formatDate(r.noticeBy)} (Periode endet ${formatDate(r.periodEnd)})`
case 'anytime': return 'jederzeit kündbar'
case 'ends': return `endet am ${formatDate(r.periodEnd)}`
case 'ended': return `beendet seit ${formatDate(r.periodEnd)}`
case 'cancelled': return r.periodEnd ? `gekündigt zum ${formatDate(r.periodEnd)}` : 'gekündigt'
default: return 'Fristen fehlen'
}
}
export default function ProviderContractsTab({ contracts, fileNames, saveContract, deleteContract }) {
const [open, setOpen] = useState(null)
const [editing, setEditing] = useState(null)
const [cancelling, setCancelling] = useState(null)
const [endsAt, setEndsAt] = useState('')
const [actionError, setActionError] = useState(null)
const today = todayIso()
const rows = contracts
.map((c) => {
const result = nextCancellationDate(c, today)
return { c, result, tone: deadlineTone(result, today) }
})
.sort((a, b) =>
TONE_ORDER[a.tone] - TONE_ORDER[b.tone] ||
(a.result.noticeBy || a.result.periodEnd || '9999').localeCompare(b.result.noticeBy || b.result.periodEnd || '9999')
)
const run = async (fn) => {
setActionError(null)
try {
await fn()
} catch (err) {
setActionError(err.message)
}
}
const confirmCancel = (c) =>
run(async () => {
await saveContract({ cancelledAt: today, endsAt: endsAt || null }, [], c.$id)
setCancelling(null)
})
const remove = (c) => {
if (window.confirm(`Vertrag „${c.provider}“ wirklich löschen?`)) run(() => deleteContract(c.$id))
}
return (
<div>
<div className="toolbar">
<Button variant="primary" onClick={() => setEditing('new')}>Neuer Vertrag</Button>
</div>
{actionError && <div className="legal-error">{actionError}</div>}
{rows.length === 0 ? (
<EmptyState title="Noch keine Verträge eingetragen" />
) : (
<table className="ui-table legal-contracts">
<thead>
<tr>
<th>Anbieter</th><th>Wofür</th><th>Kosten</th><th>Laufzeit</th><th>Kündigungsfrist</th><th>Nächster Termin</th>
</tr>
</thead>
<tbody>
{rows.map(({ c, result, tone }) => (
<Fragment key={c.$id}>
<tr className={`legal-tone-${tone}`} onClick={() => setOpen(open === c.$id ? null : c.$id)}>
<td><strong>{c.provider}</strong></td>
<td>{c.purpose || '–'}</td>
<td>{costLabel(c)}</td>
<td>{termLabel(c)}</td>
<td>{noticeLabel(c)}</td>
<td className="legal-deadline">{deadlineLabel(result)}</td>
</tr>
{open === c.$id && (
<tr className="legal-detail">
<td colSpan={6}>
{c.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
{c.note && <div className="legal-note">{c.note}</div>}
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
<div className="flex gap-2 legal-actions">
<Button size="sm" onClick={() => setEditing(c)}>Bearbeiten</Button>
{!c.cancelledAt && cancelling !== c.$id && (
<Button size="sm" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
Als gekündigt markieren
</Button>
)}
{cancelling === c.$id && (
<span className="flex gap-2 items-center">
<label>Endet am</label>
<input type="date" className="form-control" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
<Button size="sm" variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
<Button size="sm" onClick={() => setCancelling(null)}>Abbrechen</Button>
</span>
)}
<Button size="sm" variant="danger" onClick={() => remove(c)}>Löschen</Button>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
{editing && (
<ContractForm
contract={editing === 'new' ? null : editing}
onSave={(doc, files) => saveContract(doc, files, editing === 'new' ? undefined : editing.$id)}
onClose={() => setEditing(null)}
/>
)}
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { Button } from '../ui'
import { todayIso } from './legalUi'
export default function VersionForm({ title, onSave, onClose }) {
const [form, setForm] = useState({ version: '', status: 'current', validFrom: todayIso(), changeNote: '' })
const [files, setFiles] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
const submit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await onSave({ ...form, version: form.version.trim() }, files)
onClose()
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
<div className="overlay-content">
<h2 className="mb-2">Neue Version: {title}</h2>
{error && <div className="legal-error">{error}</div>}
<form onSubmit={submit}>
<div className="form-group">
<label className="form-label">Version</label>
<input className="form-control" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="form-control" name="status" value={form.status} onChange={set('status')}>
<option value="current">Gültig</option>
<option value="draft">Entwurf (noch nicht live)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Gültig ab</label>
<input className="form-control" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
</div>
<div className="form-group">
<label className="form-label">Was hat sich geändert?</label>
<textarea className="form-control" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
</div>
<div className="form-group">
<label className="form-label">Dateien (PDF, HTML, Bild, Word)</label>
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</div>
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
<Button type="button" onClick={onClose}>Abbrechen</Button>
</div>
</form>
</div>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { fileUrl, fileDownloadUrl } from '../../hooks/useLegal'
/** Heutiges Datum in Ortszeit als 'YYYY-MM-DD'. */
export function todayIso() {
return new Date().toLocaleDateString('sv-SE')
}
export function FileLinks({ fileIds = [], fileNames = {} }) {
if (!fileIds.length) return <span className="faint">keine Datei</span>
return (
<span className="legal-files">
{fileIds.map((id) => (
<span key={id}>
<a href={fileUrl(id)} target="_blank" rel="noreferrer">{fileNames[id] || 'Datei'}</a>
{' '}
<a href={fileDownloadUrl(id)} className="faint">(herunterladen)</a>
</span>
))}
</span>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useState, useEffect, useCallback } from 'react'
import {
COLLECTIONS,
DATABASE_ID,
LEGAL_BUCKET_ID,
ID,
Query,
databases,
storage,
} from '../lib/appwrite'
import { planNewVersion } from '../lib/legal'
const isMissing = (err) => err?.code === 404 || /not found/i.test(err?.message || '')
async function listAll(collectionId, queries = []) {
const res = await databases.listDocuments(DATABASE_ID, collectionId, [...queries, Query.limit(5000)])
return res.documents || []
}
/** Laedt die Dateinamen des Buckets, damit Listen Namen statt IDs zeigen. */
async function listFileNames() {
try {
const res = await storage.listFiles(LEGAL_BUCKET_ID, [Query.limit(5000)])
return Object.fromEntries((res.files || []).map((f) => [f.$id, f.name]))
} catch {
return {}
}
}
async function uploadFiles(files = []) {
const ids = []
for (const file of files) {
const created = await storage.createFile(LEGAL_BUCKET_ID, ID.unique(), file)
ids.push(created.$id)
}
return ids
}
export function fileUrl(fileId) {
return storage.getFileView(LEGAL_BUCKET_ID, fileId).toString()
}
export function fileDownloadUrl(fileId) {
return storage.getFileDownload(LEGAL_BUCKET_ID, fileId).toString()
}
function useLoader(load) {
const [state, setState] = useState({ loading: true, error: null, missingSetup: false })
const [data, setData] = useState(null)
const refresh = useCallback(async () => {
try {
setData(await load())
setState({ loading: false, error: null, missingSetup: false })
} catch (err) {
console.error('Rechtliches laden:', err)
setData(null)
setState({ loading: false, error: isMissing(err) ? null : err.message, missingSetup: isMissing(err) })
}
}, [load])
useEffect(() => {
refresh()
}, [refresh])
return { data, ...state, setError: (error) => setState((s) => ({ ...s, error })), refresh }
}
const loadTexts = async () => {
const [documents, versions, fileNames] = await Promise.all([
listAll(COLLECTIONS.LEGAL_DOCUMENTS, [Query.orderAsc('sort')]),
listAll(COLLECTIONS.LEGAL_VERSIONS),
listFileNames(),
])
return { documents, versions, fileNames }
}
export function useLegalDocuments() {
const { data, loading, error, missingSetup, setError, refresh } = useLoader(loadTexts)
const versions = data?.versions || []
// Neue Version anlegen; eine juengere gueltige Version loest die bisherige ab.
const addVersion = async (documentKey, input, files) => {
const fileIds = await uploadFiles(files)
const plan = planNewVersion(versions, { ...input, documentKey })
await databases.createDocument(DATABASE_ID, COLLECTIONS.LEGAL_VERSIONS, ID.unique(), {
...plan.create,
fileIds,
})
await applyUpdates(plan.updates, 'Neue Version gespeichert')
}
// Entwurf gueltig machen: dieselbe Abloese-Regel wie beim Anlegen.
const promoteDraft = async (version) => {
const plan = planNewVersion(versions, { ...version, status: 'current' })
const { status, validTo } = plan.create
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEGAL_VERSIONS, version.$id, { status, validTo })
await applyUpdates(plan.updates, 'Version ist gültig')
}
const applyUpdates = async (updates, done) => {
try {
for (const u of updates) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEGAL_VERSIONS, u.id, u.patch)
}
await refresh()
} catch (err) {
await refresh()
setError(`${done}, alte Version konnte nicht abgelöst werden: ${err.message}`)
}
}
const updateVersion = async (id, patch) => {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEGAL_VERSIONS, id, patch)
await refresh()
}
return {
documents: data?.documents || [],
versions,
fileNames: data?.fileNames || {},
loading,
error,
missingSetup,
addVersion,
promoteDraft,
updateVersion,
refresh,
}
}
const loadContracts = async () => {
const [contracts, fileNames] = await Promise.all([
listAll(COLLECTIONS.PROVIDER_CONTRACTS, [Query.orderAsc('provider')]),
listFileNames(),
])
return { contracts, fileNames }
}
export function useProviderContracts() {
const { data, loading, error, missingSetup, refresh } = useLoader(loadContracts)
const saveContract = async (values, files, id) => {
const newIds = await uploadFiles(files)
const existing = id ? data?.contracts.find((c) => c.$id === id)?.fileIds || [] : []
const doc = { ...values, fileIds: [...existing, ...newIds] }
if (id) await databases.updateDocument(DATABASE_ID, COLLECTIONS.PROVIDER_CONTRACTS, id, doc)
else await databases.createDocument(DATABASE_ID, COLLECTIONS.PROVIDER_CONTRACTS, ID.unique(), doc)
await refresh()
}
const deleteContract = async (id) => {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.PROVIDER_CONTRACTS, id)
await refresh()
}
return {
contracts: data?.contracts || [],
fileNames: data?.fileNames || {},
loading,
error,
missingSetup,
saveContract,
deleteContract,
refresh,
}
}
+8 -1
View File
@@ -1,4 +1,4 @@
import { Client, Account, Databases, ID, Query } from 'appwrite'
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite'
/** Ticket-System Appwrite-Projekt (Fallback wenn keine .env beim Build) */
const DEFAULT_PROJECT_ID = '6a1058610003c5a13a05'
@@ -23,6 +23,7 @@ const client = new Client().setEndpoint(endpoint).setProject(projectId)
export const account = new Account(client)
export const databases = new Databases(client)
export const storage = new Storage(client)
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
@@ -35,8 +36,14 @@ export const COLLECTIONS = {
LEADS: 'leads',
LEAD_SETTINGS: 'leadSettings',
LEAD_PIPELINE: 'leadPipeline',
LEGAL_DOCUMENTS: 'legalDocuments',
LEGAL_VERSIONS: 'legalVersions',
PROVIDER_CONTRACTS: 'providerContracts',
}
/** Dateien im Bereich Rechtliches (Versionen und Anbieter-Vertraege) */
export const LEGAL_BUCKET_ID = 'legal-files'
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
export function hasAppwriteSession() {
if (typeof window === 'undefined' || !projectId) return false
+138
View File
@@ -0,0 +1,138 @@
// Reine Logik fuer den Bereich Rechtliches: Kuendigungsfristen der Anbieter-Vertraege
// und die Versionsgeschichte der Rechtstexte. Datumswerte sind immer 'YYYY-MM-DD'.
const DAY = 24 * 60 * 60 * 1000
function toUtc(date) {
const [y, m, d] = date.split('-').map(Number)
return Date.UTC(y, m - 1, d)
}
function fromUtc(ms) {
return new Date(ms).toISOString().slice(0, 10)
}
function addDays(date, n) {
return fromUtc(toUtc(date) + n * DAY)
}
function daysBetween(from, to) {
return Math.round((toUtc(to) - toUtc(from)) / DAY)
}
function isSet(value) {
return value !== null && value !== undefined && value !== ''
}
/** Monate addieren; der Tag wird auf das Monatsende geklemmt (31.01. + 1 = 28./29.02.). */
export function addMonthsClamped(date, n) {
const [y, m, d] = date.split('-').map(Number)
const index = y * 12 + (m - 1) + n
const year = Math.floor(index / 12)
const month = index - year * 12
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return fromUtc(Date.UTC(year, month, Math.min(d, lastDay)))
}
function noticeDate(periodEnd, contract) {
const value = Number(contract.noticeValue)
return contract.noticeUnit === 'days'
? addDays(periodEnd, -value)
: addMonthsClamped(periodEnd, -value)
}
/**
* Naechster Kuendigungstermin eines Anbieter-Vertrags.
* state: active (noticeBy = spaetester Kuendigungstag), anytime (jederzeit kuendbar),
* ends (laeuft ohne Verlaengerung aus), ended, cancelled, incomplete (Angaben fehlen).
*/
export function nextCancellationDate(contract, today) {
if (isSet(contract.cancelledAt)) {
return { state: 'cancelled', periodEnd: contract.endsAt || null, noticeBy: null }
}
if (!isSet(contract.startDate) || !isSet(contract.termMonths)) {
return { state: 'incomplete', periodEnd: null, noticeBy: null }
}
const term = Number(contract.termMonths)
if (term === 0) return { state: 'anytime', periodEnd: null, noticeBy: null }
const renewal = Number(contract.renewalMonths) || 0
let periodEnd = addMonthsClamped(contract.startDate, term)
if (renewal === 0) {
return { state: periodEnd < today ? 'ended' : 'ends', periodEnd, noticeBy: null }
}
if (!isSet(contract.noticeValue) || !isSet(contract.noticeUnit)) {
return { state: 'incomplete', periodEnd: null, noticeBy: null }
}
// Jede Periode vom Beginn aus rechnen: vom schon geklemmten Ende weiterzuzaehlen
// wuerde nach einem Februar auf dem 28. haengen bleiben.
for (let k = 1; noticeDate(periodEnd, contract) < today; k++) {
periodEnd = addMonthsClamped(contract.startDate, term + k * renewal)
}
return { state: 'active', periodEnd, noticeBy: noticeDate(periodEnd, contract) }
}
/** Farbe in der Vertragsliste: rot <= 30 Tage, orange <= 90 Tage bis zum Kuendigungstermin. */
export function deadlineTone(result, today) {
if (result.state === 'cancelled' || result.state === 'ended') return 'grey'
if (result.state === 'incomplete') return 'missing'
if (result.state !== 'active' || !result.noticeBy) return 'normal'
const days = daysBetween(today, result.noticeBy)
if (days <= 30) return 'red'
if (days <= 90) return 'orange'
return 'normal'
}
// Tag vor dem neuen Beginn, aber nie vor dem eigenen Beginn (Abloesung am selben Tag).
function dayBeforeOrSame(nextFrom, ownFrom) {
const before = addDays(nextFrom, -1)
return ownFrom && before < ownFrom ? ownFrom : before
}
const newness = (v) => `${v.validFrom || ''}|${v.$createdAt || ''}`
/**
* Aktuell gueltige Version; bei mehreren (z. B. halb gescheiterte Abloesung) die mit dem
* juengsten validFrom, bei gleichem Datum die zuletzt angelegte.
*/
export function currentVersion(versions) {
return versions
.filter((v) => v.status === 'current')
.reduce((best, v) => (!best || newness(v) > newness(best) ? v : best), null)
}
/**
* Was beim Anlegen einer Version zu tun ist: das neue Dokument und die Aenderungen an
* bestehenden Versionen. Eine juengere gueltige Version loest die aktuelle ab; eine
* nachgetragene aeltere Fassung wird direkt als abgeloest einsortiert.
*/
export function planNewVersion(versions, input) {
const siblings = versions.filter(
(v) => v.documentKey === input.documentKey && v.$id !== input.$id
)
if (input.status !== 'current') {
return { create: { ...input, validTo: null }, updates: [] }
}
const current = currentVersion(siblings)
if (!current || input.validFrom >= current.validFrom) {
const updates = current
? [{ id: current.$id, patch: { status: 'superseded', validTo: dayBeforeOrSame(input.validFrom, current.validFrom) } }]
: []
return { create: { ...input, validTo: null }, updates }
}
const nextFrom = siblings
.filter((v) => v.status !== 'draft' && v.validFrom > input.validFrom)
.map((v) => v.validFrom)
.sort()[0]
return {
create: { ...input, status: 'superseded', validTo: addDays(nextFrom, -1) },
updates: [],
}
}
/** 'YYYY-MM-DD' -> 'dd.mm.yyyy' */
export function formatDate(date) {
if (!date) return '–'
const [y, m, d] = date.slice(0, 10).split('-')
return `${d}.${m}.${y}`
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from 'react'
import { EmptyState, PageHeader, Tabs } from '../components/ui'
import { useLegalDocuments, useProviderContracts } from '../hooks/useLegal'
import LegalTextsTab from '../components/legal/LegalTextsTab'
import ProviderContractsTab from '../components/legal/ProviderContractsTab'
const TABS = [
{ id: 'texte', label: 'Unsere Rechtstexte' },
{ id: 'anbieter', label: 'Verträge mit Anbietern' },
]
export default function LegalPage() {
const [tab, setTab] = useState('texte')
const texts = useLegalDocuments()
const contracts = useProviderContracts()
const active = tab === 'texte' ? texts : contracts
let body
if (active.loading) body = <div className="faint">Lädt …</div>
else if (active.missingSetup) {
body = (
<EmptyState
title="Rechtliches ist noch nicht eingerichtet"
hint="Die Appwrite-Sammlungen fehlen noch (Setup-Skript scripts/setup-legal.mjs)."
/>
)
} else if (tab === 'texte') body = <LegalTextsTab {...texts} />
else body = <ProviderContractsTab {...contracts} />
return (
<div className="page">
<PageHeader title="Rechtliches" subtitle="Rechtstexte mit Versionsgeschichte und Verträge mit Anbietern" />
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{active.error && <div className="legal-error">{active.error}</div>}
{body}
</div>
)
}
+33
View File
@@ -1108,3 +1108,36 @@ textarea.form-control {
color: var(--text-faint);
font-size: 12px;
}
/* --- Rechtliches --- */
.legal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); align-items: start; margin-top: var(--sp-4); }
.legal-grid > .legal-error { grid-column: 1 / -1; }
.legal-doc-summary { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; cursor: pointer; }
.legal-toggle { margin-left: auto; font-size: 13px; }
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; color: var(--accent); }
.legal-history { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
.legal-version { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px 10px; font-size: 14px; }
.legal-version-current { border-color: var(--ok); background: rgba(16, 185, 129, 0.08); }
.legal-version-draft { border-style: dashed; }
.legal-version-superseded { color: var(--text-muted); }
.legal-version-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.legal-note { white-space: pre-wrap; margin: 4px 0; color: var(--text-muted); font-size: 13px; }
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; }
.legal-files a { color: var(--accent); }
.legal-error { background: var(--danger); color: #fff; padding: 8px 12px; border-radius: var(--r-sm); margin: 8px 0; }
.legal-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.legal-contracts { margin-top: var(--sp-2); }
.legal-contracts tbody tr { cursor: pointer; }
.legal-tone-red td { background: rgba(239, 68, 68, 0.10); }
.legal-tone-red .legal-deadline { color: var(--danger); font-weight: 700; }
.legal-tone-orange td { background: rgba(245, 158, 11, 0.10); }
.legal-tone-orange .legal-deadline { color: #b45309; font-weight: 600; }
.legal-tone-missing .legal-deadline { color: var(--text-faint); font-style: italic; }
.legal-tone-grey td { color: var(--text-faint); }
.legal-detail td { background: var(--surface-2); cursor: default; }
.legal-actions { margin-top: 8px; flex-wrap: wrap; }
@media (max-width: 700px) {
.legal-grid { grid-template-columns: 1fr; }
.legal-form-row { grid-template-columns: 1fr; }
.legal-contracts { display: block; overflow-x: auto; }
}
+147
View File
@@ -0,0 +1,147 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
addMonthsClamped,
nextCancellationDate,
deadlineTone,
currentVersion,
planNewVersion,
formatDate,
} from '../src/lib/legal.js'
const yearly = {
startDate: '2025-03-12',
termMonths: 12,
renewalMonths: 12,
noticeValue: 1,
noticeUnit: 'months',
}
test('addMonthsClamped klemmt auf Monatsende', () => {
assert.equal(addMonthsClamped('2026-01-31', 1), '2026-02-28')
assert.equal(addMonthsClamped('2028-01-31', 1), '2028-02-29')
assert.equal(addMonthsClamped('2026-03-15', 12), '2027-03-15')
assert.equal(addMonthsClamped('2026-03-31', -1), '2026-02-28')
})
test('laufender Jahresvertrag', () => {
assert.deepEqual(nextCancellationDate(yearly, '2026-09-30'), {
state: 'active',
periodEnd: '2027-03-12',
noticeBy: '2027-02-12',
})
})
test('Kündigungstermin heute zählt noch, danach nächste Periode', () => {
assert.equal(nextCancellationDate(yearly, '2027-02-12').noticeBy, '2027-02-12')
const next = nextCancellationDate(yearly, '2027-02-13')
assert.equal(next.periodEnd, '2028-03-12')
assert.equal(next.noticeBy, '2028-02-12')
})
test('Kündigungsfrist in Tagen', () => {
const r = nextCancellationDate({ ...yearly, noticeValue: 30, noticeUnit: 'days' }, '2026-09-30')
assert.equal(r.periodEnd, '2027-03-12')
assert.equal(r.noticeBy, '2027-02-10')
})
test('Start am 31. läuft nicht in den Folgemonat über', () => {
const r = nextCancellationDate(
{ startDate: '2026-01-31', termMonths: 1, renewalMonths: 1, noticeValue: 0, noticeUnit: 'days' },
'2026-02-01'
)
assert.equal(r.periodEnd, '2026-02-28')
})
test('jederzeit kündbar', () => {
const r = nextCancellationDate({ startDate: '2025-01-01', termMonths: 0 }, '2026-09-30')
assert.equal(r.state, 'anytime')
assert.equal(r.noticeBy, null)
})
test('ohne Verlängerung: endet, danach beendet', () => {
const c = { startDate: '2026-01-01', termMonths: 12, renewalMonths: 0 }
assert.deepEqual(nextCancellationDate(c, '2026-09-30'), { state: 'ends', periodEnd: '2027-01-01', noticeBy: null })
assert.equal(nextCancellationDate(c, '2027-01-02').state, 'ended')
})
test('gekündigt', () => {
const r = nextCancellationDate({ ...yearly, cancelledAt: '2026-09-01', endsAt: '2027-03-12' }, '2026-09-30')
assert.deepEqual(r, { state: 'cancelled', periodEnd: '2027-03-12', noticeBy: null })
})
test('unvollständige Angaben', () => {
assert.equal(nextCancellationDate({ ...yearly, startDate: '' }, '2026-09-30').state, 'incomplete')
assert.equal(nextCancellationDate({ ...yearly, noticeValue: null }, '2026-09-30').state, 'incomplete')
assert.equal(nextCancellationDate({ startDate: '2026-01-01' }, '2026-09-30').state, 'incomplete')
})
test('deadlineTone', () => {
const today = '2026-09-30'
const at = (noticeBy) => deadlineTone({ state: 'active', noticeBy, periodEnd: null }, today)
assert.equal(at('2026-10-30'), 'red')
assert.equal(at('2026-10-31'), 'orange')
assert.equal(at('2026-12-29'), 'orange')
assert.equal(at('2026-12-30'), 'normal')
assert.equal(deadlineTone({ state: 'cancelled' }, today), 'grey')
assert.equal(deadlineTone({ state: 'ended' }, today), 'grey')
assert.equal(deadlineTone({ state: 'incomplete' }, today), 'missing')
assert.equal(deadlineTone({ state: 'anytime' }, today), 'normal')
assert.equal(deadlineTone({ state: 'ends' }, today), 'normal')
})
const v5 = { $id: 'v5', documentKey: 'agb', version: 'v5', status: 'current', validFrom: '2026-05-01', validTo: null }
const v4 = { $id: 'v4', documentKey: 'agb', version: 'v4', status: 'superseded', validFrom: '2026-01-10', validTo: '2026-04-30' }
test('neue gültige Version löst die aktuelle ab', () => {
const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v6', status: 'current', validFrom: '2026-09-01' })
assert.deepEqual(r.updates, [{ id: 'v5', patch: { status: 'superseded', validTo: '2026-08-31' } }])
assert.equal(r.create.validTo, null)
assert.equal(r.create.status, 'current')
})
test('Entwurf löst nichts ab', () => {
const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v6', status: 'draft', validFrom: '2026-12-01' })
assert.deepEqual(r.updates, [])
assert.equal(r.create.status, 'draft')
})
test('Nachtrag einer alten Fassung löst die aktuelle nicht ab', () => {
const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v4b', status: 'current', validFrom: '2026-02-01' })
assert.deepEqual(r.updates, [])
assert.equal(r.create.status, 'superseded')
assert.equal(r.create.validTo, '2026-04-30')
})
test('currentVersion nimmt bei zwei gültigen die jüngere', () => {
const twin = { ...v5, $id: 'v6', version: 'v6', validFrom: '2026-09-01' }
assert.equal(currentVersion([v4, v5, twin]).$id, 'v6')
assert.equal(currentVersion([v4]), null)
})
test('formatDate', () => {
assert.equal(formatDate('2026-09-30'), '30.09.2026')
assert.equal(formatDate(''), '–')
assert.equal(formatDate(null), '–')
})
test('currentVersion: bei gleichem validFrom gewinnt die zuletzt angelegte', () => {
const a = { ...v5, $id: 'a', validFrom: '2026-09-30', $createdAt: '2026-09-30T10:00:00.000Z' }
const b = { ...v5, $id: 'b', validFrom: '2026-09-30', $createdAt: '2026-09-30T11:00:00.000Z' }
assert.equal(currentVersion([a, b]).$id, 'b')
assert.equal(currentVersion([b, a]).$id, 'b')
})
test('Abloesung am selben Tag: validTo nie vor validFrom', () => {
const r = planNewVersion([v5], { documentKey: 'agb', version: 'v6', status: 'current', validFrom: '2026-05-01' })
assert.deepEqual(r.updates, [{ id: 'v5', patch: { status: 'superseded', validTo: '2026-05-01' } }])
})
test('Verlaengerungen ab dem 31. driften nicht auf den 28.', () => {
const r = nextCancellationDate(
{ startDate: '2025-01-31', termMonths: 1, renewalMonths: 1, noticeValue: 1, noticeUnit: 'days' },
'2025-04-05'
)
assert.equal(r.periodEnd, '2025-04-30')
assert.equal(r.noticeBy, '2025-04-29')
})
+30
View File
@@ -0,0 +1,30 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { COLLECTIONS, waitForAttributes } from '../scripts/setup-legal.mjs'
const indexOn = (id, attr) =>
COLLECTIONS.find((c) => c.id === id).indexes?.some((i) => i.attributes.includes(attr))
test('Indizes fuer alle Abfragen (equal/orderAsc)', () => {
assert.ok(indexOn('legalVersions', 'documentKey'))
assert.ok(indexOn('legalVersions', 'source'))
assert.ok(indexOn('legalDocuments', 'sort'))
assert.ok(indexOn('providerContracts', 'provider'))
})
test('waitForAttributes wartet, bis alle Attribute verfuegbar sind', async () => {
let calls = 0
const db = {
listAttributes: async () => {
calls++
return { attributes: [{ key: 'a', status: 'available' }, { key: 'b', status: calls < 3 ? 'processing' : 'available' }] }
},
}
await waitForAttributes(db, 'x', { intervalMs: 1, timeoutMs: 1000 })
assert.equal(calls, 3)
})
test('waitForAttributes bricht bei fehlgeschlagenem Attribut ab', async () => {
const db = { listAttributes: async () => ({ attributes: [{ key: 'b', status: 'failed', error: 'kaputt' }] }) }
await assert.rejects(waitForAttributes(db, 'x', { intervalMs: 1, timeoutMs: 1000 }), /b/)
})