Compare commits
9
Commits
3b6a821687
...
c24b3d43c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c24b3d43c8 | ||
|
|
c8c1d84794 | ||
|
|
c7c1e338a3 | ||
|
|
67b9439145 | ||
|
|
31be2af4c3 | ||
|
|
1212173cb4 | ||
|
|
0105179e21 | ||
|
|
701ff1a8e8 | ||
|
|
f120ec2ef3 |
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
@@ -18,6 +18,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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 /> }] : []),
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user