16 Commits

Author SHA1 Message Date
karim f88825ebe0 feat(server-mode): gehostete Web-GUI fest an einen Server binden
VITE_SERVER_MODE=1 (vom Dockerfile.app gesetzt) → die App ist die Web-GUI
GENAU DIESES Servers: keine Lokal/Server-Wahl (BackendChoice), kein
Server-Adress-Wechsel, kein Verbinden auf andere Instanzen. Nur Login auf
diesem Server. Tauri (lokale DMG) setzt die Flag NIE → behält volle Wahl
(Lokal / beliebige Server-IP).

- adapter.js: isServerMode-Export; im Server-Modus fest SupabaseAdapter mit
  Build-URL/Key, localStorage-Werte erzwungen (kein User-Override)
- App.jsx: BackendChoice im Server-Modus überspringen
- Login.jsx: Verbindungs-Switch + Server-Adressfeld im Server-Modus ausblenden

Beide Builds verifiziert: Server-Build brennt URL ein, Normal-Build nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:03:53 +02:00
karim afc6163b2d feat(schema): create_studio_for_user RPC (service_role) für HOST-Provisioning
RAPPORT-HOST provisioniert serverseitig (kein auth.uid()), daher braucht es
eine service_role-Variante von create_studio_with_admin mit expliziter
User-ID. Legt zusätzlich das Profil an (profiles.username/display_name sind
NOT NULL, fürs erste Instanz-Login nötig). NUR an service_role gegrantet —
nie an authenticated, sonst könnte jeder User sich zum Admin machen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:15:22 +02:00
karim 44ddc5ee12 cleanup: verwaiste cloudError-State-Variable entfernt
War redundant zum bereits vorhandenen cloudUnreachable-Mechanismus
(1846a00/5a34d0a) — nirgends gesetzt/gelesen, reiner toter Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:58:40 +02:00
karim 266e7d61d4 fix(cloud): API-Fehler nicht als leere Instanz behandeln
Beim Ausloggen/Reload zeigte die App fälschlich den Erst-Setup-Wizard, wenn
listStudios fehlschlug (Kong/Server kurz nicht erreichbar): der catch setzte
cloudStudios=[] → Routing interpretierte das als '0 Studios' → CloudSetup.

Jetzt: bei Fehler bleibt cloudStudios=null + cloudError=true → Routing zeigt
den Login (mit Hinweis 'Server nicht erreichbar'), nicht den Init-Wizard. Der
Setup erscheint nur noch, wenn listStudios ERFOLGREICH 0 Studios meldet.

Ergänzt den Adapter-Fix (5a34d0a), der listStudios werfen statt [] liefern lässt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:50:50 +02:00
karim 5a34d0a60f feat(login): Hinweis-Banner wenn Server nicht erreichbar
Ergaenzt den listStudios-Fix: statt stillem Fallback sieht der User jetzt
'Server nicht erreichbar', wenn die API beim Start nicht antwortet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:01:22 +02:00
karim 1846a00d07 fix(cloud): API-Fehler nicht als 'leere Instanz' werten
listStudios() gab bei Kong/API-Fehler [] zurück, was App.jsx als '0 Studios'
interpretierte und faelschlich den Init-/Registrierungs-Screen zeigte (statt
Login). Nach Reload war Kong wieder da -> Login. Daher das 'beim Ausloggen
kommt Init'-Symptom.

- supabase-adapter.listStudios(): wirft jetzt statt [] zu verschlucken
- App.jsx: catch setzt cloudStudios=null + cloudUnreachable=true
- Routing-Guard zeigt CloudSetup nur bei !cloudUnreachable && length===0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:57:52 +02:00
karim 4c04f1cb56 fix(storage): split_part statt storage.foldername in Policies
Die Storage-API droppt/erstellt storage.foldername() bei ihren Boot-
Migrations neu. Policies, die davon abhingen, blockierten den Drop
('cannot drop function foldername') und schickten die Storage-API in
eine Crash-Loop. split_part(name,'/',1) liefert dieselbe erste
Pfad-Komponente (studio_id) ohne diese Kopplung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:18:01 +02:00
karim df69a2dc6b fix(storage): public-Spalte aus bucket-Insert entfernen
storage.buckets hat beim Postgres-Init noch keine public-Spalte (fügt die
Storage-API erst beim Boot hinzu). Der Insert brach daher mit
ON_ERROR_STOP ab und verhinderte alle folgenden Migrations — u.a.
ensure_profile (0005), wodurch die User-Anlage im Self-Host scheiterte.
Default von public ist false (Buckets privat), Spalte ist verzichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:15:01 +02:00
karim bb69cc0657 Release 0.8.2: latest.json signiert für Updater
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:55:23 +02:00
karim edee7b9f28 0.8.2: Auto-Recovery für hängende 0.8.0-Installationen + UpdateNotifier vor Login
- Auto-Recovery in adapter.js: wenn Cloud-Modus gesetzt + lokale Daten
  vorhanden + keine Cloud-Session → Cloud-Konfiguration wird beim
  nächsten App-Start automatisch zurückgenommen. Marker rapport_080_recovery
  verhindert wiederholte Auslösung.
- UpdateNotifier wird in allen Pre-Login-Screens gerendert (BackendChoice,
  Setup, MigrationScreen, CloudSetup, Login) — so kann ein hängender
  Wizard sich via Auto-Update selbst befreien.
- Tauri-Builds ignorieren VITE_SUPABASE_URL aus dem Build. Desktop-User
  geben die Server-URL immer aktiv im Login ein, statt eine irrelevante
  Default-IP vorgesetzt zu bekommen.
- Anon-Key bleibt aus dem Build (kein Geheimnis, pro Cloud-Instanz fix).
- BackendChoice zeigt nur dann eine vorkonfigurierte URL, wenn nicht in Tauri.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:54:14 +02:00
karim 2bd516a9ab Release 0.8.1: latest.json signiert für Updater
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:33:42 +02:00
karim 40a28d5ff5 0.8.1: Fix Auto-Cloud-Switch beim Update von 0.7 auf 0.8
Beim Upgrade von 0.7 auf 0.8 wurden Lokal-Installationen ungewollt in den
Cloud-Modus geschoben, weil das Production-Build VITE_SUPABASE_URL enthielt
und adapter.js dies als Default-Cloud-URL gesetzt hat — auch wenn bereits
lokale Daten in localStorage vorhanden waren.

Fix in adapter.js: Auto-Cloud-Switch nur noch wenn
  - kein rapport_backend_chosen gesetzt
  - UND keine lokalen Daten (studio_data_v1) vorhanden
  - UND nicht in Tauri (Desktop-User wählen immer aktiv)

Damit klaut der Web-Deploy weiter automatisch Cloud-Konfig (kein BackendChoice
für Browser-User mit Server-URL), aber Tauri-Updates und Browser-User mit
bestehenden Lokal-Daten bleiben unangetastet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:31:42 +02:00
karim 0257f98524 Release 0.8.0: latest.json signiert für Updater
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:15:47 +02:00
karim 27b1057cd4 Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)
Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server.
Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync.

Storage-Architektur
- src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter
- src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends
- Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus

Postgres-Schema (supabase/migrations/0001–0010)
- 29 Tabellen, multi-tenant via studio_id + Row-Level-Security
- Audit-Spalten (created_by/updated_by/at) + Trigger
- Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen)
- Realtime-Publication für Live-Sync
- RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing),
  list_studios, load_persons_for_studio, attach_user_to_studio

Cloud-Features (App)
- BackendChoice.jsx als Erst-Screen «Lokal oder Cloud»
- CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung
- Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen
- ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event
- Realtime: Änderungen zwischen Browsern ohne Reload sichtbar
- Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen
- Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion)
- Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen
- Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig

Web-Deploy
- deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080
- .env.production.example: Build-time Cloud-URL
- DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager

Doku
- README.md: Cloud-Variante prominent erklärt
- ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle
- DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM

Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml.
Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:08:00 +02:00
karim c71feddf63 Doku & Aufräumen: CLAUDE.md/ARCHITECTURE.md, Tag-Schema, Legacy-Views weg
CLAUDE.md (Kurzform: was zu tun/lassen ist) und ARCHITECTURE.md
(vollständige Repo-Karte mit Verzeichnis, Datenfluss, View-Inventar,
Updater-Pipeline, Schwachstellen) als neue Onboarding-Dokumente.

Tag-Schema in Doku und Skript-Kommentar an die tatsächliche Konvention
angeglichen: Gitea-Tag ohne v-Prefix (latest.json-URL nutzt
/releases/download/<VERSION>/). Betrifft scripts/release.sh, README.md
und ARCHITECTURE.md §9+§10.

Legacy-Views Contacts.jsx und Clients.jsx entfernt — durch Persons.jsx
ersetzt, in NAV_ITEMS nicht mehr verlinkt, kein Import mehr im Code.
ARCHITECTURE.md §5/§12/§14 entsprechend aktualisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:27:39 +02:00
karim 0fc4dd0e08 Release 0.7.0: latest.json + Tag-Schema im Release-Script
Tag-Konvention auf Gitea ist <VERSION> ohne v-Prefix (wie schon
bei 0.6.0). Script-Output und URL in latest.json entsprechend
angepasst.
2026-05-16 02:11:30 +02:00
42 changed files with 5360 additions and 1069 deletions
+9
View File
@@ -0,0 +1,9 @@
# Kopie nach .env.local und Werte einsetzen.
# .env.local ist via .gitignore ausgeschlossen — niemals committen.
#
# Lokales Supabase (OrbStack via `supabase start`):
# URL ist immer http://127.0.0.1:54321, Anon-Key kommt aus `supabase status`
# Mac-Mini im LAN: URL = http://<mac-mini.local>:54321 oder Tailscale-IP
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=
+13
View File
@@ -0,0 +1,13 @@
# Kopiere dies nach .env.production, wenn die App auf dem Mac Mini deployed wird.
# Vite ersetzt VITE_*-Variablen beim `npm run build` direkt im Bundle.
#
# Beispiel-Werte für ein Mac-Mini-Setup im LAN:
# VITE_SUPABASE_URL=http://mac-mini.local:54321
#
# Bei extern erreichbarer Instanz (über Nginx Proxy Manager):
# VITE_SUPABASE_URL=https://api.rapport.kgva.ch
#
# Der Anon-Key kommt aus `supabase status` auf dem Mac Mini.
VITE_SUPABASE_URL=http://mac-mini.local:54321
VITE_SUPABASE_ANON_KEY=
+2
View File
@@ -11,6 +11,8 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env.production
.env.development
# Claude Code # Claude Code
.claude/ .claude/
+440
View File
@@ -0,0 +1,440 @@
# RAPPORT — Architektur-Übersicht
> Studio-Management für Architekturbüros. Tauri 2.x Desktop-App, React 19 Frontend, Rust-Backend (minimal), localStorage-only Persistierung. Solo-Dev: Karim.
> Dieses Dokument ist die **Karte** der Codebase. Es ersetzt nicht das Lesen einzelner Dateien, soll aber verhindern, dass jede Session bei Null anfangen muss.
---
## 1. Mentales Modell in einem Absatz
RAPPORT ist eine **monolithische SPA**: ein React-Root in [App.jsx](src/App.jsx) hält den **gesamten** App-State in einem `useState({...})`, persistiert ihn synchron in `localStorage` unter dem Key `studio_data_v1`, und übergibt ihn als Props an lazy-geladene Views. Es gibt **kein Routing-Framework** (View-Wechsel via String-State), **kein State-Library** (kein Redux/Context/Zustand), **kein TypeScript**, **kein CSS-Framework** (alles inline `style={{}}` oder ein 700-Zeilen `<style>`-Block in App.jsx). Der **Rust-Teil** ist 109 Zeilen und macht nur drei Dinge: System-Tray, Window-Hide-on-Close, Plugin-Registrierung (Updater, Process, Log). Es gibt **keine `#[tauri::command]`** — Frontend ↔ Backend kommuniziert nur über das Event `rapport:navigate` (Tray → Frontend). Alle Daten sind im WebView-`localStorage`, nichts wird in Rust gespeichert.
**Konsequenz:** Wenn etwas mit Daten passiert, ist es JS. Wenn etwas mit dem Fenster/Tray/Update passiert, ist es Rust. Es gibt keine dritte Stelle.
---
## 2. Verzeichnis-Karte
```
APP/
├── src/ React-Frontend (kein TS, nur .jsx)
│ ├── main.jsx Entry: createRoot().render(<App />)
│ ├── App.jsx Root: State, Navigation, Auth, Migrationen, Sidebar, Modals [823 Z.]
│ ├── constants.js STORAGE_KEY, NAV_ITEMS, defaultData, SIA-Phasen, Statusfarben [252 Z.]
│ ├── utils.js Business-Logik: Kalkulation, Format, QR-Bill, Hash, CSV, Lohn [678 Z.]
│ ├── storage/adapter.js Storage-Adapter (Promise-API): LocalStorageAdapter + SupabaseAdapter, Modus-Auswahl
│ ├── storage/supabase-adapter.js Cloud-Implementation: load/save/realtime/signIn/signUp/invite/reset
│ ├── storage/supabase-mappers.js fromDB / toDB für ~22 Entities (camelCase ↔ snake_case, JSONB-Spread)
│ ├── storage/migrations.js `applyMigrations(parsed, defaultData)` — Schema-Migrations als reine Funktion
│ ├── views/ 31 Top-Level-Screens, lazy-geladen
│ ├── components/ UI.jsx (StatusBadge, Modal, FormField, …), UpdateNotifier, UpdatesSupport
│ ├── print/ PrintComponents.jsx — alle Druckansichten (Rechnung, QR, Brief, …)
│ ├── utils/updater.js Wrapper um @tauri-apps/plugin-updater + plugin-process
│ ├── assets/ hero.png
│ ├── App.css, index.css Globale CSS-Reset + Variablen
│ └── index.jsx
├── supabase/ Cloud-Schema (Postgres + Supabase Auth + Storage + Realtime)
│ ├── config.toml Lokale Supabase-Konfiguration (site_url, redirects)
│ └── migrations/ 10 SQL-Migrations:
│ 0001 initial schema (29 Tabellen, RLS, Audit-Trigger)
│ 0002 storage buckets (receipts, logos)
│ 0003 seed defaults pro neuem Studio
│ 0004 realtime publication
│ 0005 signup RPCs (ensure_profile, create_studio_with_admin)
│ 0006 list_studios RPC (für Login-Dropdown)
│ 0007 persons sharing across studios
│ 0008 load_persons RPC (lokale + geteilte)
│ 0009 attach_user_to_studio (Mitarbeiter einladen)
│ 0010 studio name + setup_completed in settings
├── deploy/ Static-Hosting der Web-App via nginx-Container
│ ├── docker-compose.yml nginx:alpine, mountet ../dist
│ └── nginx.conf SPA-Routing (try_files $uri /index.html)
├── .env.production.example Template für VITE_SUPABASE_URL / ANON_KEY
├── DEPLOY.md Deploy-Anleitung (LAN-only + extern via NPM)
├── src-tauri/ Rust-Backend (Tauri 2.10.3)
│ ├── src/main.rs 6 Zeilen — delegiert zu app_lib::run()
│ ├── src/lib.rs 103 Zeilen — Tray, Window-Events, Plugins
│ ├── Cargo.toml tauri, plugin-updater, plugin-process, plugin-log, serde
│ ├── tauri.conf.json Updater-URL, Public-Key, CSP, Bundle-Targets
│ ├── capabilities/default.json core:default, core:webview:allow-print, updater:default, process:allow-restart
│ ├── icons/ .icns, .ico, mehrere PNGs (icon.png = Quelle)
│ ├── gen/ AUTO-GENERIERT — nie editieren
│ ├── target/ Cargo-Build-Output (~2 GB) — nie editieren
│ └── build.rs Ruft tauri_build::build()
├── scripts/release.sh Build + Sign + latest.json
├── latest.json Updater-Manifest (im Repo, weil über main.HTTP abgerufen)
├── package.json version, scripts (dev, build, lint), Deps
├── eslint.config.js Flat-Config, sehr minimal
├── vite.config.js Nur @vitejs/plugin-react
├── index.html Vite-Entry
├── public/ favicon.svg, icons.svg
├── README.md Setup-Doku (Release-Sektion veraltet, siehe §6)
└── .claude/ Lokale Claude-Code-Einstellungen (gitignored)
```
---
## 3. Datenfluss — Wie Updates wirklich passieren
```
User-Interaktion in View
▼ onChange / onClick
View ruft eine der zwei Props auf:
├── update(key, value) → save({ ...data, [key]: value }) // Top-Level-Field
└── saveAll(newData) → save(newData) // Atomar
setData(newData)
storage.save(newData) ← Storage-Adapter
React re-rendert die Hierarchie
```
**Storage-Adapter** ([src/storage/adapter.js](src/storage/adapter.js)) ist die einzige Stelle, die `studio_data_v1` schreibt. Zwei Implementationen mit identischem Promise-Interface:
- `LocalStorageAdapter` — Browser-localStorage, sync wrapped in Promises
- `SupabaseAdapter` ([supabase-adapter.js](src/storage/supabase-adapter.js)) — Cloud via Postgres + REST + Realtime; pro `data.*`-Array eine eigene Tabelle (Mappers in [supabase-mappers.js](src/storage/supabase-mappers.js))
Auswahl-Logik beim Modul-Load: `localStorage.rapport_backend` entscheidet (`"local"` | `"cloud"`). Bei Production-Build mit gesetztem `VITE_SUPABASE_URL` wird automatisch Cloud (für Web-Deploy). Schnittstelle: `hasExistingData()`, `load()`, `save(data)`, `clear()`. SupabaseAdapter zusätzlich: `signIn/signUp/signOut`, `setStudioId`, `myStudios/listStudios`, `createStudio`, `inviteMember`, `requestPasswordReset`, `subscribeToChanges`. App.jsx zeigt Boot-Spinner ([ViewFallback](src/App.jsx)) bis Initial-Load durch ist.
**Per-Device-UI-State** (Dark Mode, Zoom, Sidebar-Collapse, Changelog-Seen, `rapport_backend`/`rapport_cloud_url`) bleibt außerhalb des Adapters in direktem `localStorage` — kommt nicht in die Cloud.
**Schema-Migrations** ([src/storage/migrations.js](src/storage/migrations.js)) sind als reine Funktion `applyMigrations(parsed, defaultData)` extrahiert, damit derselbe Code auf Local- und Cloud-geladenen Daten läuft. Beim Initial-Load in App.jsx wird das Ergebnis vom Adapter durch diese Funktion gepiped, bevor es in `useState` landet.
**Wichtig:**
- `save()` schreibt **synchron** bei jedem Update — kein Debouncing. Bei großen `data`-Objekten kann das spürbar werden.
- Es gibt **kein Backup, kein Conflict-Resolution**. Wenn der User zwei App-Fenster hat (passiert kaum, weil Single-Window), überschreibt das letzte gewinnt.
- Kein Schema-Validator beim Laden — wenn `localStorage` korrupt ist, crasht es im Render.
**`data` Top-Level-Struktur** (definiert in [constants.js](src/constants.js)):
```
{
settings, // Studio-Daten: Name, IBAN, MWST, Stundensätze, Rollen, …
persons[], // Kunden + Partner (vereint seit v0.5)
projects[], // Projekte mit SIA-Phasen / Budget / Billing-Type
timeEntries[], // Stundeneinträge
invoices[], quotes[],
expenses[], internalExpenses[],
employees[], absences[], ferienEntries[], lohnEntries[],
protocols[], deliveryNotes[], letterTemplates[],
dashboardTemplates[], appRoles[], users[]
}
```
---
## 4. Navigation & State-Verwaltung
**Navigation** ist eine String-State-Machine in [App.jsx](src/App.jsx):
- `const [view, setView] = useState("dashboard")`
- Jede View ist ein String-Identifier (`"dashboard"`, `"projects"`, `"time"`, …)
- `navigate(newView)` schiebt auf einen Ref-basierten History-Stack (Browser-ähnliches Vor/Zurück)
- Drill-down via `selectedProjectId` etc. (zweiter State neben `view`)
**Modals:** Inkonsistent — manche Views nutzen `modal: { type, id }`, andere haben mehrere separate `useState` (siehe [Invoices.jsx](src/views/Invoices.jsx): `setModal`, `setNewInvModal`, `setAkontoModal`). Es gibt keinen zentralen Modal-Manager.
**Sessions/Auth:**
- `sessionStorage.rapport_user` — angemeldeter User (gestrippt von Credentials)
- Login per PBKDF2-SHA-256 (100k Iter, 16-Byte Salt) — siehe `hashPassword`, `verifyPassword` in [utils.js](src/utils.js)
- Lockout: 5 Fehlversuche → 60s Sperre ([Login.jsx](src/views/Login.jsx))
- Auto-Upgrade: legacy plaintext-Passwörter werden beim ersten erfolgreichen Login zu PBKDF2 migriert ([App.jsx:143-161](src/App.jsx#L143-L161))
**Weitere `localStorage`-Keys (alle `rapport_*`-prefixed):**
| Key | Zweck |
|---|---|
| `studio_data_v1` | **Die Hauptdaten** (alles außer Settings unten) |
| `rapport_dark` | Dark Mode (`"1"`/`"0"`) |
| `rapport_sidebar_collapsed` | Sidebar collapsed (`"1"`/`"0"`) |
| `rapport_zoom` | UI-Zoom-Faktor |
| `rapport_changelog_seen` | Zuletzt gesehene Changelog-Version |
| `rapport_v0_5_migrated` | Marker für abgeschlossene Migration |
| `rapport_update_skipped_version` | Vom User übersprungene Update-Version |
| `rapport_update_last_check` | ISO-Timestamp letzter Update-Check |
| `sessionStorage.rapport_user` | Aktive Session |
---
## 5. Views — Inventar
Alle Views liegen in [src/views/](src/views/), werden in [App.jsx](src/App.jsx) per `React.lazy()` geladen und über Props `{ data, update, saveAll, modal, setModal, currentUser, … }` versorgt.
| View | Zeilen | Zweck | Komplexität |
|---|---:|---|---|
| [Projects.jsx](src/views/Projects.jsx) | 1781 | List + Detail, SIA-Phasen, Quoten-Zuordnung, Nachträge | **Sehr hoch** |
| [Time.jsx](src/views/Time.jsx) | 1538 | Wochen-Grid mit Drag&Drop, Tag/Woche/Monat, Absenzen | **Sehr hoch** |
| [Invoices.jsx](src/views/Invoices.jsx) | 1467 | Rechnungen, Akonto, QR-Bill, Mahnungen, Planer | **Sehr hoch** |
| [Employees.jsx](src/views/Employees.jsx) | 1298 | Multi-Tab: Stammdaten, Absenzen, Ferien, Lohnabschluss | **Sehr hoch** |
| [Quotes.jsx](src/views/Quotes.jsx) | 980 | Offerten: SIA / manuell / frei, Übernahme als Projekt | Hoch |
| [Protocols.jsx](src/views/Protocols.jsx) | 978 | Sitzungsprotokolle (Beschluss/Info/Aufgabe), Mahnung-Modul | Hoch |
| [Expenses.jsx](src/views/Expenses.jsx) | 914 | Mitarbeiter-Spesen + interne Ausgaben, Bild-Upload | Hoch |
| [Settings.jsx](src/views/Settings.jsx) | 869 | 7 Tabs: Studio, Dokumente, Team, Kalender, System, Support, Profil | **Sehr hoch** |
| [StudioBudget.jsx](src/views/StudioBudget.jsx) | 847 | Revenue-Sparklines, Aggregation Rechnungen/Quoten | Hoch |
| [Dashboard.jsx](src/views/Dashboard.jsx) | 762 | Drag&Drop Widget-Layout, Template-System | Hoch |
| [Persons.jsx](src/views/Persons.jsx) | 682 | Kunden + Partner (seit v0.5 vereint) | Mittel |
| [Setup.jsx](src/views/Setup.jsx) | 423 | 7-Step-Wizard für Neuinstallation | Mittel |
| [Pinboard.jsx](src/views/Pinboard.jsx) | 417 | Blog-artige Notizen, kategorisiert | Mittel |
| [Accounting.jsx](src/views/Accounting.jsx) | 374 | CSV-Export, Jahreszahlen, MwSt-Berechnung | Mittel |
| [Payroll.jsx](src/views/Payroll.jsx) | 344 | Monats-Lohnzettel, BVG-Sätze, Abzüge | Mittel |
| [DeliveryNotes.jsx](src/views/DeliveryNotes.jsx) | 294 | Lieferscheine | Niedrig |
| [Login.jsx](src/views/Login.jsx) | 197 | Login + Brute-Force-Lockout | Niedrig |
| [Documents.jsx](src/views/Documents.jsx) | 194 | Router zu Protokolle/Lieferscheine/Briefe | Niedrig |
| [MigrationScreen.jsx](src/views/MigrationScreen.jsx) | 141 | v0.5-Migration-Wizard (wenn alte Daten erkannt) | Niedrig |
| [Letters.jsx](src/views/Letters.jsx) | 114 | Brieftemplates mit Placeholdern (`{{client}}`, …) | Niedrig |
---
## 6. utils.js — Business-Logik-Bibliothek
[utils.js](src/utils.js) ist isoliert und gut testbar. Die wichtigsten Gruppen:
**Kalkulation:**
- `calcSIAHours(baukosten, schwierigkeit, phasen)` — SIA-102-Formel `p = Z1 + Z2/∛B`
- `calcManualHours(phases, roles)` — Stundenansatz × Stunden pro Rolle
- `deriveQuoteBudget(linkedQuotes, allQuotes, roles)` — Aggregiert Offerten zu Projekt-Budget
- `calcLohn(emp, monat, spesen, bonus)` — AHV/ALV/BVG/NBU/KTG/Quellensteuer
**Crypto / Auth:**
- `hashPassword(password, saltHex)` — PBKDF2-SHA-256, 100k Iter, via Web Crypto
- `verifyPassword(password, user)` — Constant-Time
- `withHashedPassword(user, password)` — Upgrade legacy → hashed
- `stripCredentials(user)` — Entfernt `password`, `passwordHash`, `passwordSalt`
**Sicherheit:**
- `sanitizeHtml(html)` — Allowlist (`<p>`, `<br>`, `<b>`, `<a>`, …), blockiert `<script>`, `javascript:`, `on*`-Handler
**Formatierung (de-CH):**
- `formatCHF(amount)``"CHF 1'234.50"`
- `formatDate(iso)``"14. Mai 2025"`
- `roundCHF(amount)` → 5-Rappen-Rundung
- `formatHours(minutes)``"2h 30m"`
**Schweizer QR-Rechnung:**
- `isQRIban(iban)` — IID-Range 3000031999
- `formatIban(iban)` → 4er-Blöcke
- `generateQRReference(invoiceNumber)` → 27-stellige Referenz mit Mod10-Prüfziffer
- `mod10(input)` — Schweizer Modulo-10-Algorithmus
**Templates / Nummerngenerierung:**
- `applyProjectNumberFormat`, `applyProtoNumberFormat` — Template-Syntax wie `{YYYY}/{NN}`
- `parseSeqFromNumber`, `nextProtoSeq`
- `buildReminderLetter(inv, nr, …)` — Mahnungstexte (1./2./3. Mahnung)
- `buildPdfName(format, content, settings)` — Sanitierter Dateiname
**Sonstiges:**
- `exportBuchhaltungCSV(data, year)` — Voller Jahresexport
- `migrateDashboardLayout(val)` — Alte Widget-IDs → Row-basiertes Layout
- `getFeiertageForYear`, `getWorkdaysInMonth`, `getSollStunden`
---
## 7. Print-Modul
[src/print/PrintComponents.jsx](src/print/PrintComponents.jsx) (~1200 Zeilen) exportiert `<PrintView>`, das via `setPrintContent(...)` aus App.jsx getriggert wird.
**Content-Typen:** `invoice`, `invoice+qr`, `qrbill`, `quote`, `letter`, `lieferschein`, `protokoll`, `lohn`, `studioBudget`, `buchhaltung`, `projectDetail`, `projectsOverview`, `mitarbeiterOverview`, `timeReport`.
**Druck-Trigger:** `getCurrentWebviewWindow().print()` (Tauri WebView) oder Fallback `window.print()` (Browser).
**Schweizer QR-Rechnung:** Lib `swissqrbill` (lokal installiert), erzeugt SVG für 100% akkuraten Druck. Format: 105mm × 210mm, separate `@page`-Regel.
**Styles:** Inline + `@page`, `print-color-adjust: exact`. Margins konfigurierbar über `settings.pageMargin{Top,Bottom,Left,Right}`.
---
## 8. Rust-Backend ([src-tauri/src/lib.rs](src-tauri/src/lib.rs))
**Alles in 103 Zeilen.** Keine `#[tauri::command]`. Kein Filesystem, kein HTTP, keine DB.
**Was er macht:**
1. **System-Tray** mit 5 Nav-Items (`nav:dashboard`, `nav:time`, `nav:projects`, `nav:buchhaltung`, `nav:settings`) + `show` + `quit` ([lib.rs:47-60](src-tauri/src/lib.rs#L47-L60))
2. **Tray-Click** → Fenster anzeigen + fokussieren ([lib.rs:81-90](src-tauri/src/lib.rs#L81-L90))
3. **Tray-Nav-Click**`emit("rapport:navigate", "<view>")` ans Frontend ([lib.rs:77](src-tauri/src/lib.rs#L77))
4. **Window-Close (X)** → Hide statt Quit, gesteuert durch `Arc<AtomicBool> is_quitting` ([lib.rs:25-35](src-tauri/src/lib.rs#L25-L35))
5. **Plugins registrieren:** `updater`, `process` (für Relaunch nach Update), `log` (nur Debug)
**Frontend lauscht** in [App.jsx:191](src/App.jsx#L191):
```js
listen("rapport:navigate", (event) => setView(event.payload))
```
**Capabilities** ([src-tauri/capabilities/default.json](src-tauri/capabilities/default.json)) — bewusst minimal:
- `core:default`
- `core:webview:allow-print` (für `window.print()`)
- `updater:default`
- `process:allow-restart` (für Relaunch nach Update)
- **Nichts** für `fs:*`, `shell:*`, `http:*`, `dialog:*`, `clipboard:*`
**Tauri-Plugins (Cargo.toml):**
- `tauri-plugin-updater` v2
- `tauri-plugin-process` v2
- `tauri-plugin-log` v2
- `serde` 1.0, `serde_json` 1.0
**Bekannte Fragilitäten:**
- `app.default_window_icon().unwrap()` — panicked, wenn Icon fehlt ([lib.rs:64](src-tauri/src/lib.rs#L64))
- Hardcoded `"main"`-Label für Window, Hardcoded `"nav:"`-Prefix — wenn Frontend Konventionen ändert, bricht Tray
- Keine Tests in Rust
---
## 9. Updater-Pipeline End-to-End
```
release.sh (lokal, manuell aufgerufen)
├─ liest VERSION aus tauri.conf.json + package.json (Mismatch → Exit)
├─ ⚠️ Cargo.toml wird NICHT geprüft
├─ Lädt Private Key aus ~/.tauri/rapport_updater.key (kein Passwort)
├─ npx tauri build (mit TAURI_SIGNING_PRIVATE_KEY env)
├─ findet .app.tar.gz + .sig in src-tauri/target/release/bundle/macos/
└─ schreibt latest.json (Repo-Root) mit version, signature, url, pub_date
└─ url zeigt auf Gitea Release Asset (manuell hochzuladen)
User (manuell):
├─ Gitea-Webinterface: Release mit Tag <VERSION> (ohne v-Prefix) erstellen
├─ .app.tar.gz (+ optional .dmg) hochladen
└─ git add latest.json && git commit && git push origin main
App-Start (in jeder installierten Version):
├─ UpdateNotifier.jsx: setTimeout 1.5s → checkForAppUpdate({ silent: true })
├─ Tauri-Plugin GET https://git.kgva.ch/karim/RAPPORT/raw/branch/main/latest.json
├─ Verifiziert Signature gegen pubkey aus tauri.conf.json (Minisign)
├─ Vergleicht latest.json.version mit getVersion()
└─ Wenn neuer → Modal mit "Installieren / Später / Diese Version überspringen"
Installation:
├─ update.downloadAndInstall(onProgress) // lädt von url in latest.json
└─ relaunch() (via plugin-process)
```
**Updater-Komponenten im Frontend:**
- [src/utils/updater.js](src/utils/updater.js) (49 Z.) — Wrapper, kapselt Skip-Logik in `localStorage`
- [src/components/UpdateNotifier.jsx](src/components/UpdateNotifier.jsx) (163 Z.) — Auto-Check beim Start, Modal mit Progress-Bar
- [src/components/UpdatesSupport.jsx](src/components/UpdatesSupport.jsx) (197 Z.) — Settings-Tab "Updates & Support", manueller Check, ignoriert Skip
- Custom DOM-Event: `window.dispatchEvent(new CustomEvent("rapport:check-update"))` — UpdatesSupport triggert manuell
**Aktueller `latest.json`:** nur `darwin-aarch64` (Apple Silicon). **Kein Intel-Build, kein Windows-Build, kein Linux-Build.** Wer auf x86_64-Mac oder anderem OS installiert, bekommt keine Updates.
**Signatur-Setup (Minisign):**
- Private Key: `~/.tauri/rapport_updater.key` (User-Home, **niemals** im Repo, gitignored via `*.key`)
- Public Key: base64 in `tauri.conf.json``plugins.updater.pubkey`
- Kein Passwort (`TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""`)
---
## 10. Build & Release-Workflow
**Versions-Bump betrifft drei Dateien — alle drei müssen synchron sein:**
1. [package.json](package.json) → `"version"`
2. [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json) → `"version"`
3. [src-tauri/Cargo.toml](src-tauri/Cargo.toml) → `[package] version`
**Zusätzlich für jeden Release:**
4. [src/App.jsx](src/App.jsx) → Changelog-Entry in `CHANGELOGS`-Array (hardcoded in JSX)
5. [src/App.jsx](src/App.jsx) → `rapport_changelog_seen`-Vergleichswert (im Changelog-Modal-Close-Handler)
> ⚠️ `release.sh` prüft nur 1+2. **Cargo.toml-Mismatch bleibt unbemerkt.**
**Dev-Workflow:**
```bash
npm run dev # Vite-Server auf http://localhost:3000
npx tauri dev # Native Window + HMR
npm run lint # ESLint (manuell — kein Pre-Commit-Hook)
```
**Release-Workflow:**
```bash
# 1. Versionen in package.json, tauri.conf.json, Cargo.toml + Changelog-Entry hochziehen
# 2. Commit
# 3. Release-Script:
./scripts/release.sh
# 4. In Gitea-UI: Release <VERSION> erstellen (Tag OHNE v-Prefix — latest.json-URL nutzt /<VERSION>/), .app.tar.gz hochladen
# 5. git add latest.json && git commit -m "Release X.Y.Z" && git push origin main
# 6. git tag -a <VERSION> -m "..." && git push origin <VERSION>
```
> Die [README.md](README.md)-Release-Sektion erwähnt `scripts/release.sh` nicht und ist veraltet.
---
## 11. Konventionen
**Sprache:**
- **UI-Strings: Deutsch** ("Zeiterfassung", "Buchhaltung", "Beenden")
- **Code-Identifier: Englisch** (`isQuitting`, `setView`, `currentUser`)
- **Wenig Inline-Kommentare** — wenn vorhanden, meist Deutsch
**Naming:**
- Komponenten/Views: PascalCase, eine Datei = ein Default-Export (ggf. mit Named-Exports für Sub-Views)
- Utils: camelCase
- Dateien: PascalCase für Components/Views, lowercase für constants/utils
**Styling:**
- Inline-Styles dominieren (über 200 in [Invoices.jsx](src/views/Invoices.jsx) allein)
- Globale Klassen: `.btn`, `.card`, `.pill`, `.filter-bar`, `.modal` — definiert im `<style>`-Block in [App.jsx](src/App.jsx)
- CSS-Variablen für Theming: `--bg`, `--text`, `--border`, … (Dark Mode via `data-theme`-Attribut)
- **Kein** Tailwind, **kein** CSS-Module, **kein** styled-components
**ESLint** ([eslint.config.js](eslint.config.js)): Flat-Config mit `js.configs.recommended`, `reactHooks.configs.flat.recommended`, `reactRefresh.configs.vite`. Kein Prettier, kein Husky, kein lint-staged.
**Imports:** Stdlib oben (React), dann Constants/Utils, dann lokale Components. Keine Pfad-Aliase (`~/`, `@/` werden **nicht** verwendet — relative Pfade `../foo`).
---
## 12. Wo es weh tut — Realistische Schwachstellen
1. **Vier "God Components"** über 1200 Zeilen ([Projects](src/views/Projects.jsx), [Time](src/views/Time.jsx), [Invoices](src/views/Invoices.jsx), [Employees](src/views/Employees.jsx)) — Refactoring riskant ohne Tests, Sub-Komponenten sind intern definiert statt extrahiert.
2. **App.jsx ist 823 Zeilen** und macht: Auth, State, Migration, Sidebar, Modals, Changelog, About, Print-Routing, Hotkeys, Navigation-History, Theme. Jede Änderung an App.jsx ist hochriskant — sie betrifft alles.
3. **Inline-Styles ohne Konvention** — Spacing/Farben sind über das Projekt verstreut, kein Design-Token-System.
4. **Modal-State chaotisch** — manche Views haben `{type,id}`, andere mehrere `useState`. Kein zentraler Manager.
5. **Keine Tests.** Nichts. Kein Vitest, kein Cypress, kein Rust-Test. Kalkulationen in `utils.js` wären leicht testbar.
6. **Kein TypeScript.** Bei 18k Zeilen JSX ohne Types ist jedes Schema-Refactor Risiko.
7. **Kein Error-Boundary** — wenn eine lazy-geladene View crasht, weißer Screen.
8. **`localStorage` ohne Schema-Validierung** — korrupte Daten crashen im Render.
9. **Keine CI**, keine Pre-Commit-Hooks. Linting muss man sich selbst merken.
10. **Updater nur für Apple Silicon** — wenn User x86_64-Mac/Windows/Linux hat, kein Update.
11. **README-Release-Sektion veraltet** — erwähnt `scripts/release.sh` nicht.
12. **`release.sh` prüft Cargo.toml-Version nicht** — Inkonsistenz bleibt unbemerkt.
13. **`.unwrap()` im Tray-Icon-Load** in [lib.rs:64](src-tauri/src/lib.rs#L64) — Startup-Panic möglich, wenn Icon fehlt.
---
## 13. Wenn-du-anfasst-Hinweise
| Bereich | Risiko | Notiz |
|---|---|---|
| `App.jsx` State/Auth/Migration | **Sehr hoch** | Touch nur mit klarem Auftrag, betrifft alles |
| `constants.js` `defaultData` Shape | **Hoch** | Schema-Änderung erfordert Migration (siehe Beispiele in App.jsx:56-122) |
| `utils.js` Kalkulationen | **Hoch** | Ohne Tests — Änderung an `calcSIAHours`, `calcLohn`, `generateQRReference`, `mod10` → manuell durchrechnen |
| `print/PrintComponents.jsx` | **Hoch** | SwissQR-Bill ist Pixel-genau — Layout-Bugs sichtbar erst im Druck |
| Views (Invoices/Projects/Time) | **Hoch** | Lange Files mit Edge-Cases (Mahnung, Akonto, Drag&Drop) |
| `Settings.jsx` Permissions | **Hoch** | Tangiert Rollen/Berechtigungen, Dashboard-Templates |
| `Login.jsx` Hash-Logik | **Hoch** | PBKDF2 + Migration, sicherheitsrelevant |
| `storage/adapter.js` | **Hoch** | Einzige Schreibstelle für `studio_data_v1`. API ist async (Promise) — Direkt-Zugriffe auf `localStorage[STORAGE_KEY]` in Views sind verboten |
| `storage/migrations.js` | **Hoch** | Schema-Migrations. Jede Änderung am `defaultData`-Shape erfordert hier eine Migration-Step + Test mit alten Snapshots |
| `storage/supabase-adapter.js` | **Hoch** | Cloud-Read/Write, Auth, Realtime. Bei neuer Tabelle: load() + save() + Mapper anpassen + Migration anlegen + Realtime-Publication ergänzen |
| `storage/supabase-mappers.js` | **Hoch** | fromDB ↔ toDB pro Entity. Snake/Camel-Konvention; JSONB-Spread bei settings; isShared-Flag für geteilte Personen |
| `supabase/migrations/` | **Hoch** | Cloud-Schema (29 Tabellen + RPCs). Via `supabase db reset` lokal anwendbar. Schema-Änderungen brauchen neue Migration-Datei, kein Bearbeiten bestehender |
| `views/CloudSetup.jsx` | **Mittel** | Erst-Einrichtung der Cloud (3 Schritte). Ruft `cloudInit` in App.jsx |
| `views/BackendChoice.jsx` | **Niedrig** | Modus-Wahl Lokal/Cloud bei frischer Installation |
| `views/ResetPassword.jsx` | **Mittel** | Passwort-Reset nach Mail-Link-Klick. Hängt am `PASSWORD_RECOVERY`-Event |
| `deploy/` + `DEPLOY.md` | **Niedrig** | Nginx-Container + Anleitung. Reine Doku/Hosting-Files |
| `lib.rs` Tray/Window | **Mittel** | Wenn Nav-IDs geändert werden, müssen Frontend + Rust synchron bleiben |
| `tauri.conf.json` Updater | **Sehr hoch** | Public Key ändern bricht alle bestehenden Installationen |
| `release.sh` | **Sehr hoch** | Falsche Änderung → defekte Updates beim User |
| Neue Util / neue View | Niedrig | Isoliert, safe — kopiere bestehende, entferne was du nicht brauchst |
---
## 14. Offene Fragen / Nicht-Validiertes
- Wo werden Bild-Uploads (Receipts in Expenses, Logo in Settings) gespeichert? Vermutlich Base64 in `data` → wächst `localStorage` unkontrolliert.
- Wie groß darf `data` werden, bevor `localStorage` (510 MB Limit) bricht? Aktuell ohne Monitoring.
- PDF-Export: aktuell nur `window.print()` → User-PDF-Dialog. Kein direkter File-Save.
- Multi-User-Workflow: `users[]` in `data`, aber nur ein Browser-localStorage → keine echte Mehrfachnutzung.
+97
View File
@@ -0,0 +1,97 @@
# Anweisungen für Claude
Dieses Dokument wird in **jede** Claude-Session automatisch geladen. Es ist die kürzeste Form: Was du wissen musst und was du tun/lassen sollst. Details stehen in [ARCHITECTURE.md](ARCHITECTURE.md) — **lies sie, bevor du nicht-triviale Änderungen machst.**
---
## Was das Projekt ist
**RAPPORT** — Tauri 2.x Desktop-App für Architekturbüros. React 19 (kein TypeScript), minimaler Rust-Backend (System-Tray + Updater, keine Tauri-Commands). Solo-Dev: Karim. Daten leben in `localStorage` unter Key `studio_data_v1`. macOS Apple Silicon ist die primäre Plattform.
Architektur in einem Satz: **App.jsx hält den gesamten State, übergibt ihn an lazy-geladene Views als Props, persistiert synchron in localStorage. Rust macht nur Tray + Update.**
→ Vollständige Karte: [ARCHITECTURE.md](ARCHITECTURE.md)
---
## Befehle
```bash
npm run dev # Vite-Server auf http://localhost:3000
npx tauri dev # Native App-Window mit HMR (für UI-Verifikation)
npm run lint # ESLint
npm run build # Frontend-Build (dist/)
npx tauri build # Vollständiges App-Bundle
./scripts/release.sh # Release-Build mit Signatur — NUR auf explizite Anweisung
```
Es gibt **keine Tests**, keine CI, keinen Pre-Commit-Hook. Korrektheit ist Augenmaß.
---
## Sprache
- **Antworte auf Deutsch.** Karim arbeitet auf Deutsch.
- **UI-Strings im Code: Deutsch** ("Zeiterfassung", "Buchhaltung", "Beenden").
- **Code-Identifier: Englisch** (`setView`, `currentUser`, `isQuitting`).
---
## Vor jeder Änderung — die drei Reflexe
1. **Wenn es um Daten/State geht** → schau in [App.jsx](src/App.jsx) (Top-Level-State, Migrations, `save()`, `update()`). Kein Zustand existiert ausserhalb davon.
2. **Wenn es um Logik geht** (Kalkulation, Format, Hash, QR-Bill) → schau in [utils.js](src/utils.js). Dort sind isolierte Funktionen — wahrscheinlich gibt es schon eine.
3. **Wenn es um das Datenmodell geht** → schau in [constants.js](src/constants.js) (`defaultData` ist die Shape-Referenz).
---
## Tu — Konventionen die hier gelten
- **Inline-Styles** (`style={{}}`) sind etabliert. Keine Tailwind-Vorschläge, keine CSS-Module einführen, ohne dass Karim das initiiert.
- **Globale Klassen** (`.btn`, `.card`, `.pill`, `.modal`, `.filter-bar`) sind in [App.jsx](src/App.jsx)'s `<style>`-Block definiert — nutze sie statt eigene zu erfinden.
- **CSS-Variablen** für Theming (`--bg`, `--text`, `--border`, …). Dark Mode ist `data-theme="dark"` am Root. Niemals harte Farben einbauen, ohne CSS-Variable zu prüfen.
- **Lazy-Loaded Views** — neue Top-Level-Screens in [App.jsx](src/App.jsx) mit `React.lazy()` einbinden, nicht direkt importieren.
- **Props-Pattern:** Views bekommen `{ data, update, saveAll, modal, setModal, currentUser, … }`. Kein Context, kein Redux. Wenn du State teilst, mach es über `data` oder hebe ihn in App.jsx.
- **`update(key, value)`** für Top-Level-Field-Updates, **`saveAll(newData)`** für atomare Multi-Field-Updates.
- **Relative Imports** (`../utils.js`), keine Pfad-Aliase.
- **Deutsch in UI-Strings**, auch in neuen Features.
---
## Lass — Stolperfallen
- **Kein TypeScript einführen** ohne expliziten Auftrag — die Migration wäre eigenständig zu diskutieren.
- **Keine Test-Suite "nebenbei" aufsetzen** — sinnvoll, aber separate Entscheidung.
- **Keine Context-/Redux-/Zustand-Provider hinzufügen** — das Single-Root-State-Pattern ist bewusst.
- **Keine Tauri-Commands (`#[tauri::command]`) erfinden**, ohne mit Karim zu klären. Die Architektur ist absichtlich Frontend-zentriert.
- **Keine neuen `localStorage`-Keys** erfinden, ohne `rapport_`-Prefix und Eintragung in [ARCHITECTURE.md §4](ARCHITECTURE.md).
- **Niemals Tests/Lint mit `--no-verify` umgehen** — wenn ein Pre-Commit-Hook fehlt, fehlt er aus Absicht; wenn einer da ist, ist das Karims Entscheidung.
- **`localStorage` ist die Wahrheit** — keine Versuche, parallele Persistierung (IndexedDB, Tauri AppData) hinzuzufügen.
---
## Releases — heißes Eisen
**Mach niemals einen Release auf eigene Faust.** Auch nicht "halb" (kein Versions-Bump, kein `release.sh`, kein Tag, kein `latest.json`-Edit), es sei denn Karim sagt es explizit.
Wenn ein Release gewünscht ist, denke an:
- Version in **drei** Dateien synchron: [package.json](package.json), [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json), [src-tauri/Cargo.toml](src-tauri/Cargo.toml). `release.sh` prüft Cargo.toml **nicht** — du musst manuell.
- Changelog-Entry in [App.jsx](src/App.jsx)'s `CHANGELOGS`-Array + den `rapport_changelog_seen`-Vergleichswert.
- `release.sh` braucht `~/.tauri/rapport_updater.key` — wenn fehlt, abbrechen, nicht generieren.
- Asset-Upload auf Gitea und `latest.json`-Commit sind **manuelle** Schritte.
Details: [ARCHITECTURE.md §9 + §10](ARCHITECTURE.md).
---
## UI-Verifikation
Wenn du Frontend-Änderungen machst, die optisch wirken, **starte `npx tauri dev`** und schau, ob es funktioniert. ESLint und Type-Checking gibt es hier nicht — also ist Augenschein die Verifikation. Wenn du es nicht selbst öffnen kannst, sag das explizit, anstatt "fertig" zu melden.
---
## Wenn du etwas Neues findest
Wenn du beim Arbeiten merkst, dass [ARCHITECTURE.md](ARCHITECTURE.md) falsch oder veraltet ist (z.B. neue Views, neue Konventionen, Refactor): **update sie im selben Commit**. Sie ist die Karte — wenn sie verrottet, war die Mühe umsonst.
Wenn du eine wiederkehrende Anweisung von Karim bekommst, die hier fehlt: schlag vor, sie in CLAUDE.md zu ergänzen.
+133
View File
@@ -0,0 +1,133 @@
# Rapport-Web-App Deploy
> Wer kein Tauri installieren will, kann Rapport im Browser nutzen. Diese
> Anleitung beschreibt, wie du die Web-Version auf dem Mac Mini hostest.
---
## Option 2 — Im LAN erreichbar (ohne SSL)
Ziel: jedes Gerät im Studio-Netz kann `http://<mac-mini.local>:8080` öffnen.
### Voraussetzungen
- Mac Mini hat OrbStack/Docker und Supabase wie in [README.md](README.md) eingerichtet.
- `supabase start` läuft (Supabase auf Port 54321).
- Der Mac Mini ist im LAN erreichbar (`mac-mini.local` per Bonjour oder feste IP).
### Schritt-für-Schritt
1. **Repo auf den Mac Mini kopieren** (falls noch nicht da).
2. **`.env.production` anlegen** (im Repo-Root):
```bash
cp .env.production.example .env.production
```
Dann den Anon-Key aus `supabase status` einsetzen und `VITE_SUPABASE_URL`
auf die LAN-Adresse stellen, z.B. `http://mac-mini.local:54321`.
3. **Build:**
```bash
npm install
npm run build
```
Erzeugt `dist/` mit statischen Files (~500 kB).
4. **Static-Container starten:**
```bash
docker compose -f deploy/docker-compose.yml up -d
```
Liefert `dist/` auf Port `8080` aus.
5. **Test:** anderes Gerät im LAN öffnet `http://<mac-mini.local>:8080`
im Browser. Login-Screen erscheint, Cloud-Modus ist bereits korrekt
konfiguriert (Server-URL ist gebakene `.env.production`).
### Update auf neue Version
```bash
git pull
npm run build
docker compose -f deploy/docker-compose.yml restart
```
---
## Option 3 — Auch extern erreichbar (mit SSL)
Zusätzlich zu Option 2: Nginx Proxy Manager (NPM) auf dem Mac Mini macht
Domain-Routing und holt Let's-Encrypt-Zertifikate.
### Voraussetzungen
- NPM läuft bereits auf dem Mac Mini (typisch Port 81 für UI, 80/443 für
Traffic).
- DNS-Records `app.rapport.kgva.ch` und `api.rapport.kgva.ch` zeigen auf den
Mac Mini (z.B. via Tailscale Funnel, DDNS oder feste IP).
### Schritt-für-Schritt
1. **In NPM-UI zwei Proxy-Hosts anlegen:**
| Domain | Forward zu | SSL |
|---|---|---|
| `app.rapport.kgva.ch` | `mac-mini-ip:8080` | Force HTTPS + Let's Encrypt |
| `api.rapport.kgva.ch` | `mac-mini-ip:54321` | Force HTTPS + Let's Encrypt |
Beide mit «Block Common Exploits» und «Cache Assets» an. Für `api.*`
zusätzlich Websocket-Support an (Realtime braucht WS).
2. **`.env.production` umstellen auf die externe URL:**
```env
VITE_SUPABASE_URL=https://api.rapport.kgva.ch
VITE_SUPABASE_ANON_KEY=<aus supabase status>
```
3. **Supabase-Config anpassen** in `supabase/config.toml`:
```toml
[api]
external_url = "https://api.rapport.kgva.ch"
[auth]
site_url = "https://app.rapport.kgva.ch"
additional_redirect_urls = ["https://app.rapport.kgva.ch"]
```
Dann `supabase stop && supabase start`, damit GoTrue die neuen URLs nutzt.
4. **Rebuild + restart:**
```bash
npm run build
docker compose -f deploy/docker-compose.yml restart
```
5. **Test:** von außen (Smartphone mit Mobile-Data) `https://app.rapport.kgva.ch`
öffnen → Login funktioniert.
---
## Troubleshooting
**Other devices see "Login" without auto-filled Server-URL?**
→ `.env.production` enthält nicht den richtigen `VITE_SUPABASE_URL`, oder das
Build wurde vor der Änderung gemacht. `npm run build` neu ausführen.
**Realtime funktioniert nicht von extern?**
→ NPM braucht «Websocket Support» an im Proxy-Host für `api.*`.
**Auth-Mails kommen nicht an?**
→ Lokales Supabase nutzt Inbucket (Port 54324). Für Production brauchst du
einen echten SMTP-Server in `config.toml`.
**Mac Mini wird neugestartet, Rapport-App weg?**
→ `restart: unless-stopped` im docker-compose sorgt für Auto-Start. Plus:
Supabase als LaunchAgent registrieren, damit `supabase start` beim Boot läuft.
+40 -9
View File
@@ -1,6 +1,12 @@
# Rapport # Rapport
Desktop-App auf Basis von **Tauri 2** und **React 19**, gebaut mit **Vite**. Daten liegen ausschliesslich lokal im Browser-`localStorage` — es gibt kein Backend. Studio-Management für Architekturbüros. **Tauri 2** + **React 19** + **Vite**. Läuft als Desktop-App (macOS/Windows/Linux) **oder** als Web-App im Browser.
Daten liegen wahlweise:
- **Lokal** im Browser-`localStorage` — Solo-Setup, kein Server nötig
- **Cloud** auf einer eigenen Supabase-Instanz (z.B. Mac Mini im Büro) — Multi-User mit Realtime-Sync, Multi-Studio, Mitarbeiter-Einladung
Der Modus wird beim ersten Öffnen gewählt und kann später umgeschaltet werden.
## Voraussetzungen ## Voraussetzungen
@@ -83,32 +89,57 @@ Zusätzlich im UI-Changelog: [`src/App.jsx`](src/App.jsx) — Konstante `CHANGEL
``` ```
. .
├── src/ Frontend (React) ├── src/ Frontend (React)
│ ├── App.jsx Root-Komponente, Routing, Auth-State │ ├── App.jsx Root-Komponente, Routing, Auth-State, Cloud-Resume
│ ├── main.jsx React-Mount │ ├── main.jsx React-Mount
│ ├── constants.js Default-Daten, SIA-Phasen, Statusfarben │ ├── constants.js Default-Daten, SIA-Phasen, Statusfarben
│ ├── utils.js Hashing, Sanitizer, Formatter, Berechnungen │ ├── utils.js Hashing, Sanitizer, Formatter, Berechnungen
│ ├── storage/ Storage-Adapter (Local + Supabase) + Migrations
│ ├── components/UI.jsx Wiederverwendbare UI-Bausteine │ ├── components/UI.jsx Wiederverwendbare UI-Bausteine
│ ├── views/ Module (Dashboard, Time, Invoices, …) │ ├── views/ Module (Dashboard, Time, Invoices, Setup, CloudSetup, …)
│ └── print/ Print-Komponenten (PDF/QR-Rechnung) │ └── print/ Print-Komponenten (PDF/QR-Rechnung)
├── src-tauri/ Tauri-Wrapper (Rust) ├── src-tauri/ Tauri-Wrapper (Rust)
│ ├── src/ main.rs + lib.rs │ ├── src/ main.rs + lib.rs
│ ├── capabilities/ Permission-Definitionen │ ├── capabilities/ Permission-Definitionen
│ ├── icons/ App-Icons aller Plattformen │ ├── icons/ App-Icons aller Plattformen
│ └── tauri.conf.json Window, Bundle, CSP │ └── tauri.conf.json Window, Bundle, CSP
├── supabase/ Cloud-Schema
│ ├── config.toml lokale Supabase-Konfiguration
│ └── migrations/ SQL-Migrations (~10 Files, multi-tenant + RLS)
├── deploy/ Static-Hosting der Web-App
│ ├── docker-compose.yml nginx-Container, liefert dist/ aus
│ └── nginx.conf SPA-Routing
├── public/ Statische Assets (favicon, icons.svg) ├── public/ Statische Assets (favicon, icons.svg)
├── index.html Vite-Entry ├── index.html Vite-Entry
├── DEPLOY.md Deploy-Anleitung (LAN + extern via NPM)
└── vite.config.js └── vite.config.js
``` ```
## Daten & Persistenz ## Daten & Persistenz
- Alles wird unter dem Key `rapport_data` in `localStorage` gehalten — siehe [`src/constants.js`](src/constants.js) → `STORAGE_KEY`. Rapport hat zwei Storage-Backends mit einheitlicher Schnittstelle in [`src/storage/adapter.js`](src/storage/adapter.js):
- Beim ersten Start ohne Daten erscheint ein **Setup-Assistent** ([`src/views/Setup.jsx`](src/views/Setup.jsx)). Bestehende Daten ohne `setupCompleted`-Flag triggern den **Migrations-Screen** ([`src/views/MigrationScreen.jsx`](src/views/MigrationScreen.jsx)).
- Backup/Restore: **Einstellungen → Daten exportieren / importieren** — speichert/lädt den gesamten Store als JSON. ### Lokal-Modus (Standard, kein Server)
- Alles liegt unter dem Key `studio_data_v1` in `localStorage`.
- Beim ersten Start: **BackendChoice** (`Lokal / Cloud`) → bei Lokal-Wahl klassischer **Setup-Assistent** ([`src/views/Setup.jsx`](src/views/Setup.jsx)).
- Backup/Restore: **Einstellungen → Daten exportieren / importieren** als JSON.
### Cloud-Modus (eigener Server)
- Postgres-Schema mit ~30 Tabellen, multi-tenant via `studio_id` + Row-Level-Security (siehe [`supabase/migrations/`](supabase/migrations/)).
- **Realtime-Sync**: Änderungen im Browser A erscheinen ohne Reload in Browser B.
- **Multi-Studio**: ein Account kann mehrere Studios verwalten, Personen zwischen Studios teilen.
- **Mitarbeiter-Einladung** als Admin-Aktion in den Settings — keine Self-Registrierung.
- **Passwort-Reset** via Email (`/auth/v1/recover`).
- Cloud-Setup mit Supabase auf einem Mac Mini / Docker-Host: siehe **[DEPLOY.md](DEPLOY.md)**.
Erst-Einrichtung einer leeren Cloud-Instanz: 3-Schritt-Wizard ([`src/views/CloudSetup.jsx`](src/views/CloudSetup.jsx)) erscheint automatisch, wenn der Browser auf eine Instanz ohne Studios trifft.
## Sicherheit ## Sicherheit
- **Passwörter**: PBKDF2-SHA-256 mit 100 000 Iterationen und zufälligem Salt; Verifikation in konstanter Zeit. Legacy-Klartext-Passwörter werden beim ersten erfolgreichen Login transparent zu Hashes migriert. - **Lokal-Modus** Passwörter: PBKDF2-SHA-256 mit 100 000 Iterationen und zufälligem Salt; Verifikation in konstanter Zeit. Legacy-Klartext-Passwörter werden beim ersten erfolgreichen Login transparent zu Hashes migriert.
- **Cloud-Modus** Auth: Supabase Auth (bcrypt-Passwörter, JWT-Sessions), separat von der Lokal-User-Tabelle.
- **Row-Level-Security**: jede Cloud-Datentabelle hat eine Policy `is_studio_member(studio_id)` — DB-Ebene garantiert Tenant-Trennung.
- **Login-Schutz**: 5 Fehlversuche → 60 s Sperre pro Tab (`sessionStorage`). - **Login-Schutz**: 5 Fehlversuche → 60 s Sperre pro Tab (`sessionStorage`).
- **HTML-Sanitizer**: Brieftexte werden vor Print/Render durch eine Allowlist gefiltert (`sanitizeHtml` in [`src/utils.js`](src/utils.js)) — kein `javascript:`, kein `on*`-Handler. - **HTML-Sanitizer**: Brieftexte werden vor Print/Render durch eine Allowlist gefiltert (`sanitizeHtml` in [`src/utils.js`](src/utils.js)) — kein `javascript:`, kein `on*`-Handler.
- **Datenexport**: Legacy-Klartext-Passwörter werden beim Export gestrippt; nur Hashes verlassen die App. - **Datenexport**: Legacy-Klartext-Passwörter werden beim Export gestrippt; nur Hashes verlassen die App.
@@ -121,8 +152,8 @@ Zusätzlich im UI-Changelog: [`src/App.jsx`](src/App.jsx) — Konstante `CHANGEL
# 1. Versionen anheben (package.json, tauri.conf.json, Cargo.toml) # 1. Versionen anheben (package.json, tauri.conf.json, Cargo.toml)
# 2. Changelog in src/App.jsx ergänzen # 2. Changelog in src/App.jsx ergänzen
# 3. Commit + Tag # 3. Commit + Tag
git tag -a v0.7.0 -m "Rapport 0.7" git tag -a 0.7.0 -m "Rapport 0.7" # ohne v-Prefix — latest.json verlinkt /releases/download/<VERSION>/
git push origin main v0.7.0 git push origin main 0.7.0
# 4. Bundle bauen # 4. Bundle bauen
npx tauri build npx tauri build
+23
View File
@@ -0,0 +1,23 @@
# Static-Hosting für die Rapport-Web-App.
#
# Liefert das `dist/`-Verzeichnis (npm run build) über nginx aus.
# Läuft parallel zur lokalen Supabase-Instanz auf demselben Mac Mini.
#
# Start: docker compose -f deploy/docker-compose.yml up -d
# Stop: docker compose -f deploy/docker-compose.yml down
# Update: npm run build → docker compose -f deploy/docker-compose.yml restart
#
# Erreichbarkeit:
# - LAN: http://<mac-mini-ip>:8080 oder http://<mac-mini.local>:8080
# - Extern: über Nginx Proxy Manager mit Reverse-Proxy auf Port 8080
services:
rapport-app:
image: nginx:alpine
container_name: rapport-app
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ../dist:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+30
View File
@@ -0,0 +1,30 @@
# nginx-Config für die Rapport-Web-App.
#
# Eine SPA hat nur eine echte HTML-Datei (index.html); alle "Routen" werden
# vom React-Frontend gerendert. Daher: für jeden unbekannten Pfad das index.html
# ausliefern, sonst geben Reload/Direct-Links einen 404.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Statische Assets mit langem Cache (Vite hängt Hashes an Dateinamen)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA-Fallback: alle anderen Pfade → index.html (kein Cache)
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
# Sicherheitsheader
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.8.2",
"notes": "Rapport 0.8.2",
"pub_date": "2026-05-23T17:54:59Z",
"platforms": {
"darwin-aarch64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUjV0czVoWDR4NHFSRmtHVUhpSkFlMDFwVS9XRGZjOGh1MnoybDNVcWh4ZkpIcGFQTFhVTHg5TVJ1SFA5L2kxbVQ5NUYvYmRsdlVmTHExcWZ4NThqeUZvQmk4WCtXTkEwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5NTU4ODk5CWZpbGU6UkFQUE9SVCBQUkUtUkVMRUFTRS5hcHAudGFyLmd6Cjdvc2VDeW0rbEQvbnJDeDJzbTRIS1VQT1l0VU5pRmNNT1g0Wkc4NXB3UENGVjFPUFdRM0gveE1jcENKZjFMUTRCQ2tMMHNTUDBORkNiYVpOODZZdUJRPT0K",
"url": "https://git.kgva.ch/karim/RAPPORT/releases/download/0.8.2/RAPPORT%20PRE-RELEASE.app.tar.gz"
}
}
}
+97 -5
View File
@@ -1,13 +1,14 @@
{ {
"name": "rapport", "name": "rapport",
"version": "0.6.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rapport", "name": "rapport",
"version": "0.6.0", "version": "0.7.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.106.1",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"react": "^19.2.5", "react": "^19.2.5",
@@ -857,6 +858,90 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz",
"integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz",
"integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz",
"integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz",
"integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz",
"integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz",
"integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.1",
"@supabase/functions-js": "2.106.1",
"@supabase/postgrest-js": "2.106.1",
"@supabase/realtime-js": "2.106.1",
"@supabase/storage-js": "2.106.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@@ -1760,6 +1845,15 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2544,9 +2638,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rapport", "name": "rapport",
"private": true, "private": true,
"version": "0.7.0", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.106.1",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"react": "^19.2.5", "react": "^19.2.5",
+6 -5
View File
@@ -11,7 +11,7 @@
# 3) schreibt latest.json im Repo-Root mit URLs auf Gitea-Release-Assets # 3) schreibt latest.json im Repo-Root mit URLs auf Gitea-Release-Assets
# #
# Danach manuell: # Danach manuell:
# - auf Gitea einen Release mit Tag v<VERSION> erstellen # - auf Gitea einen Release mit Tag <VERSION> erstellen (OHNE v-Prefix — latest.json verlinkt /releases/download/<VERSION>/)
# - die .app.tar.gz und (optional) die .dmg als Assets hochladen # - die .app.tar.gz und (optional) die .dmg als Assets hochladen
# - latest.json committen + auf main pushen # - latest.json committen + auf main pushen
@@ -42,7 +42,7 @@ case "$ARCH" in
esac esac
echo "→ Build Rapport $VERSION ($PLATFORM_KEY)" echo "→ Build Rapport $VERSION ($PLATFORM_KEY)"
TAURI_SIGNING_PRIVATE_KEY_PATH="$KEY_PATH" \ TAURI_SIGNING_PRIVATE_KEY="$(cat "$KEY_PATH")" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \ TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \
npx tauri build npx tauri build
@@ -56,9 +56,10 @@ if [ -z "$TAR_GZ" ] || [ ! -f "$SIG_FILE" ]; then
fi fi
ASSET_NAME=$(basename "$TAR_GZ") ASSET_NAME=$(basename "$TAR_GZ")
ASSET_URL_NAME=$(printf '%s' "$ASSET_NAME" | sed 's/ /%20/g')
SIGNATURE=$(cat "$SIG_FILE") SIGNATURE=$(cat "$SIG_FILE")
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="$GITEA_REPO/releases/download/v$VERSION/$ASSET_NAME" DOWNLOAD_URL="$GITEA_REPO/releases/download/$VERSION/$ASSET_URL_NAME"
NOTES=${RELEASE_NOTES:-"Rapport $VERSION"} NOTES=${RELEASE_NOTES:-"Rapport $VERSION"}
@@ -85,8 +86,8 @@ echo " Platform: $PLATFORM_KEY"
echo " latest.json wurde im Repo-Root geschrieben." echo " latest.json wurde im Repo-Root geschrieben."
echo echo
echo "Nächste Schritte:" echo "Nächste Schritte:"
echo " 1) Auf Gitea Release v$VERSION erstellen und folgende Assets hochladen:" echo " 1) Auf Gitea Release mit Tag $VERSION erstellen und folgende Assets hochladen:"
echo " - $ASSET_NAME" echo " - $ASSET_NAME"
echo " - (optional) DMG für Erstinstallation" echo " - (optional) DMG für Erstinstallation"
echo " 2) latest.json committen + auf main pushen:" echo " 2) latest.json committen + auf main pushen:"
echo " git add latest.json && git commit -m 'Release v$VERSION' && git push origin main" echo " git add latest.json && git commit -m 'Release $VERSION' && git push origin main"
+1 -1
View File
@@ -2880,7 +2880,7 @@ dependencies = [
[[package]] [[package]]
name = "rapport" name = "rapport"
version = "0.7.0" version = "0.8.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rapport" name = "rapport"
version = "0.7.0" version = "0.8.2"
description = "Rapport — Studio-Management für Architekturbüros" description = "Rapport — Studio-Management für Architekturbüros"
authors = ["Karim Gabriele Varano <karim@gabrielevarano.ch>"] authors = ["Karim Gabriele Varano <karim@gabrielevarano.ch>"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "RAPPORT PRE-RELEASE", "productName": "RAPPORT PRE-RELEASE",
"version": "0.7.0", "version": "0.8.2",
"identifier": "com.karimgabrielevarano.rapport", "identifier": "com.karimgabrielevarano.rapport",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+310 -99
View File
@@ -1,8 +1,13 @@
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react"; import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react";
import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js"; import { NAV_ITEMS, defaultData } from "./constants.js";
import { migrateDashboardLayout, verifyPassword, withHashedPassword, stripCredentials } from "./utils.js"; import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js";
import { storage, isCloudBackend, isServerMode } from "./storage/adapter.js";
import { applyMigrations } from "./storage/migrations.js";
import Login from "./views/Login.jsx"; import Login from "./views/Login.jsx";
import Setup from "./views/Setup.jsx"; import Setup from "./views/Setup.jsx";
import BackendChoice from "./views/BackendChoice.jsx";
import CloudSetup from "./views/CloudSetup.jsx";
import ResetPassword from "./views/ResetPassword.jsx";
import MigrationScreen from "./views/MigrationScreen.jsx"; import MigrationScreen from "./views/MigrationScreen.jsx";
import UpdateNotifier from "./components/UpdateNotifier.jsx"; import UpdateNotifier from "./components/UpdateNotifier.jsx";
@@ -46,88 +51,69 @@ function ViewFallback() {
} }
export default function App() { export default function App() {
const [data, setData] = useState(() => { // Initial-Load läuft asynchron, damit derselbe Pfad später Cloud-Backends bedienen kann.
// `data` wird mit defaultData initialisiert (statt null), damit alle synchronen Reads
// wie `data.appRoles` während des Initial-Renders nicht crashen. `loading` zeigt den
// Boot-Spinner, bis der echte Snapshot da ist (LocalStorage <50ms, Cloud ggf. länger).
const [data, setData] = useState(defaultData);
const [loading, setLoading] = useState(true);
const [isNewInstall, setIsNewInstall] = useState(false);
// Cloud-spezifisch: Liste der Studios auf der Instanz (für Erst-Setup-Check).
// null = noch nicht geladen; Array = geladen.
const [cloudStudios, setCloudStudios] = useState(null);
// true, wenn listStudios() fehlschlug (Kong/API nicht erreichbar). Wird
// genutzt, um NICHT fälschlich den Init-/Registrierungs-Screen zu zeigen —
// ein API-Fehler ist nicht dasselbe wie "Instanz hat 0 Studios".
const [cloudUnreachable, setCloudUnreachable] = useState(false);
// Passwort-Reset-Flow: Supabase löst beim Klick auf Reset-Link in der Mail
// ein PASSWORD_RECOVERY-Event aus → wir zeigen dann das Reset-Formular.
const [passwordRecovery, setPasswordRecovery] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
if (isCloudBackend) {
// Cloud-Reload-Fall: wenn sowohl Session (sessionStorage.rapport_user)
// als auch das gewählte Studio (rapport_studio_id) noch vorhanden sind,
// stellen wir den Adapter wieder her und laden direkt — ohne dass der
// User sich neu einloggen muss.
const sessionUser = (() => {
try { return JSON.parse(sessionStorage.getItem("rapport_user")); } catch { return null; }
})();
const savedStudioId = sessionStorage.getItem("rapport_studio_id");
if (sessionUser && savedStudioId) {
storage.setStudioId(savedStudioId);
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const parsed = await storage.load();
if (stored) { if (!cancelled && parsed) {
const parsed = JSON.parse(stored); setData(applyMigrations(parsed, defaultData));
let merged = { ...defaultData, ...parsed, settings: { ...defaultData.settings, ...parsed.settings } };
// Migrate: clients[] + contacts[] → persons[]
if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) {
const idMap = {};
const persons = [];
const usedContactIds = new Set();
for (const c of merged.clients || []) {
const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId);
persons.push({
...c,
isAuftraggeber: true,
isPartner: !!linked,
type: c.type || linked?.type || "",
note: c.note || linked?.note || "",
honorarOffers: c.honorarOffers || linked?.honorarOffers || [],
contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []),
linkedContactId: undefined,
linkedClientId: undefined,
});
if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; }
} }
for (const ct of merged.contacts || []) { } catch (e) {
if (usedContactIds.has(ct.id)) continue; console.error("Cloud-Resume load failed:", e);
persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined });
}
const remapProjects = (merged.projects || []).map(p => ({
...p,
projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })),
}));
const remapProtocols = (merged.protocols || []).map(p => ({
...p,
entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })),
}));
merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined };
}
// Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz)
const allQuotes = merged.quotes || [];
const projects = (merged.projects || []).map(p => {
if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) {
const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) {
return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 };
} }
} }
return p; // Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt.
}); // Bei Fehler (Kong/API down): NICHT [] setzen (das hieße "Instanz leer" →
// Migrate: add r-projektleiter if missing, seed dashboardTemplateId from defaultData // fälschlich Init-Screen). Stattdessen Unreachable-Flag → Login bleibt.
const roleDefMap = (defaultData.appRoles || []).reduce((acc, r) => { acc[r.id] = r; return acc; }, {}); try {
const roles = (merged.appRoles || defaultData.appRoles).map(r => ({ const list = await storage.listStudios?.();
...r, if (!cancelled) { setCloudStudios(list || []); setCloudUnreachable(false); }
dashboardTemplateId: r.dashboardTemplateId || roleDefMap[r.id]?.dashboardTemplateId || null, } catch (e) {
permissions: (() => { console.error("listStudios failed:", e);
let perms = r.permissions; if (!cancelled) { setCloudStudios(null); setCloudUnreachable(true); }
if (perms && r.id === "r-projektleiter" && !perms.includes("mitarbeiter")) perms = [...perms, "mitarbeiter"];
if (perms && !perms.includes("settings")) perms = [...perms, "settings"];
return perms;
})(),
}));
if (!roles.find(r => r.id === "r-projektleiter") && roleDefMap["r-projektleiter"]) {
const adminIdx = roles.findIndex(r => r.id === "r-admin");
roles.splice(adminIdx + 1, 0, roleDefMap["r-projektleiter"]);
} }
// Migrate user-level dashboardWidgets to Row[] format if (!cancelled) setLoading(false);
const users = (merged.users || []).map(u => ({ return;
...u,
dashboardWidgets: u.dashboardWidgets ? migrateDashboardLayout(u.dashboardWidgets) : undefined,
}));
// Ensure dashboardTemplates exist (old data won't have them)
const dashboardTemplates = merged.dashboardTemplates?.length ? merged.dashboardTemplates : defaultData.dashboardTemplates;
return { ...merged, projects, appRoles: roles, users, dashboardTemplates };
} }
} catch {} const hasData = await storage.hasExistingData();
return defaultData; if (cancelled) return;
}); setIsNewInstall(!hasData);
const [isNewInstall] = useState(() => !localStorage.getItem(STORAGE_KEY)); const parsed = await storage.load();
if (cancelled) return;
setData(parsed ? applyMigrations(parsed, defaultData) : defaultData);
setLoading(false);
})();
return () => { cancelled = true; };
}, []);
const [currentUser, setCurrentUser] = useState(() => { const [currentUser, setCurrentUser] = useState(() => {
try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; } try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; }
}); });
@@ -137,11 +123,11 @@ export default function App() {
setCurrentUser(safe); setCurrentUser(safe);
}; };
// Used by the Login screen — never exposes the user list (with passwords) to the view. // Used by the Login screen — never exposes the user list (with passwords) to the view.
// Async because PBKDF2 hashing happens off the main event loop via WebCrypto. // Routes by backend: cloud → Supabase Auth, local → PBKDF2 gegen data.users.
// Legacy plaintext passwords are accepted ONCE and transparently upgraded to const verifyLogin = async (usernameOrEmail, password, opts = {}) => {
// PBKDF2 hashes on first successful login. if (isCloudBackend) return cloudSignIn(usernameOrEmail, password, opts.studioId);
const verifyLogin = async (username, password) => {
const u = (data.users || []).find(x => x.username === username); const u = (data.users || []).find(x => x.username === usernameOrEmail);
if (!u) return null; if (!u) return null;
const ok = await verifyPassword(password, u); const ok = await verifyPassword(password, u);
if (!ok) return null; if (!ok) return null;
@@ -153,14 +139,100 @@ export default function App() {
return upgraded; return upgraded;
} catch (e) { } catch (e) {
console.error("Passwort-Migration fehlgeschlagen:", e); console.error("Passwort-Migration fehlgeschlagen:", e);
// fall through — still let the user in with legacy plaintext
} }
} }
handleLogin(u); handleLogin(u);
return u; return u;
}; };
// Cloud-Erst-Einrichtung: einmaliger Bootstrap-Pfad, wenn die Cloud-Instanz
// noch leer ist (0 Studios). Im laufenden Betrieb gibt es keinen Self-Signup
// — Mitarbeiter werden via Admin-Aktion eingeladen.
// `extraSettings` enthält optionale Stammdaten (Adresse, IBAN, MwSt, …),
// die nach createStudio in die studio_settings geschrieben werden.
const cloudInit = async (email, password, displayName, studioName, extraSettings = {}) => {
try {
const signUpRes = await storage.signUp(email, password);
if (!signUpRes.ok) return { ok: false, error: signUpRes.error };
const username = (email.split("@")[0] || "user").replace(/[^a-zA-Z0-9._-]/g, "");
await storage.ensureProfile(username, displayName);
const baseSlug = studioName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
const slug = `${baseSlug || "studio"}-${Date.now().toString(36)}`;
const studioId = await storage.createStudio(studioName, slug);
storage.setStudioId(studioId);
sessionStorage.setItem("rapport_studio_id", studioId);
const parsed = await storage.load();
let merged = applyMigrations(parsed, defaultData);
// Optionale Stammdaten anwenden + sofort speichern, damit sie persistiert
// sind, bevor der User irgendwo durchklickt (sonst geht beim nächsten
// Reload Adresse/IBAN verloren).
if (Object.keys(extraSettings).length > 0) {
merged = { ...merged, settings: { ...merged.settings, ...extraSettings } };
await storage.save(merged);
}
setData(merged);
handleLogin({
id: signUpRes.user.id,
username,
displayName,
role: "admin",
appRoleId: "r-admin",
});
return { ok: true };
} catch (e) {
console.error("Cloud init fehlgeschlagen:", e);
return { ok: false, error: e?.message || "Unbekannter Fehler" };
}
};
// Cloud-Login: signIn → Studio wählen (Dropdown im Login oder erstes) → data laden.
// `preferredStudioId` kommt aus dem Login-Dropdown wenn Multi-Studio-Instanz.
const cloudSignIn = async (email, password, preferredStudioId = null) => {
try {
const result = await storage.signIn(email, password);
if (!result) return null;
if (result.studios.length === 0) {
console.warn("Cloud-Login OK, aber User ist in keinem Studio Mitglied.");
return null;
}
// Wenn ein Studio vorgewählt wurde, prüfen ob der User dort Member ist
const membership = preferredStudioId
? result.studios.find(m => m.studio_id === preferredStudioId)
: result.studios[0];
if (!membership) {
console.warn("User ist im gewählten Studio nicht Mitglied.");
return null;
}
storage.setStudioId(membership.studio_id);
sessionStorage.setItem("rapport_studio_id", membership.studio_id);
const parsed = await storage.load();
setData(applyMigrations(parsed, defaultData));
const user = {
id: result.user.id,
username: result.profile?.username || result.user.email,
displayName: result.profile?.display_name || result.user.email,
role: membership.app_role_id === "r-admin" ? "admin" : "user",
appRoleId: membership.app_role_id,
};
handleLogin(user);
return user;
} catch (e) {
console.error("Cloud signIn fehlgeschlagen:", e);
return null;
}
};
const handleLogout = () => { const handleLogout = () => {
sessionStorage.removeItem("rapport_user"); sessionStorage.removeItem("rapport_user");
sessionStorage.removeItem("rapport_studio_id");
if (isCloudBackend) {
storage.signOut?.().catch(() => {});
}
setCurrentUser(null); setCurrentUser(null);
}; };
const handleSetupComplete = (newData) => { const handleSetupComplete = (newData) => {
@@ -231,8 +303,8 @@ export default function App() {
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
const [printContent, setPrintContent] = useState(null); const [printContent, setPrintContent] = useState(null);
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1"); const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.7"); const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8.2");
const [changelogVersion, setChangelogVersion] = useState("0.7"); const [changelogVersion, setChangelogVersion] = useState("0.8.2");
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"])); const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
@@ -304,16 +376,75 @@ export default function App() {
const save = useCallback((newData) => { const save = useCallback((newData) => {
setData(newData); setData(newData);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); } catch {} storage.save(newData).catch(e => console.error("Storage save failed:", e));
}, []); }, []);
const update = useCallback((key, value) => { const update = useCallback((key, value) => {
save({ ...data, [key]: value }); save({ ...data, [key]: value });
}, [data, save]); }, [data, save]);
// Cloud: Passwort-Reset-Event abfangen — der Klick auf den Mail-Link führt
// zurück zur App mit hash-Token. Zwei Pfade:
// 1. URL-Hash beim Mount checken (Supabase JS parsed schon vor useEffect)
// 2. onAuthStateChange als Fallback
useEffect(() => {
if (!isCloudBackend || !storage.client) return;
if (typeof window !== "undefined" && window.location.hash.includes("type=recovery")) {
setPasswordRecovery(true);
}
const { data: sub } = storage.client.auth.onAuthStateChange((event) => {
if (event === "PASSWORD_RECOVERY") setPasswordRecovery(true);
});
return () => { sub?.subscription?.unsubscribe?.(); };
}, []);
// Cloud: Realtime-Subscription + Refresh bei Tab-Focus / Visibility-Change.
// Damit sieht User A live, wenn User B im anderen Browser was ändert — und
// wenn der Tab im Hintergrund war, holen wir beim Zurückkommen den aktuellen
// Stand. Refresh ist debounced (500ms), damit Batch-Inserts nicht 25 Loads
// hintereinander triggern.
useEffect(() => {
if (!isCloudBackend || !currentUser) return;
let cancelled = false;
let timer = null;
const refresh = async () => {
try {
const parsed = await storage.load();
if (!cancelled && parsed) {
setData(applyMigrations(parsed, defaultData));
}
} catch (e) {
console.error("Cloud refresh failed:", e);
}
};
const scheduleRefresh = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = null; refresh(); }, 500);
};
storage.subscribeToChanges?.(scheduleRefresh);
const onVisibility = () => {
if (document.visibilityState === "visible") scheduleRefresh();
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("focus", scheduleRefresh);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
storage.unsubscribeFromChanges?.();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("focus", scheduleRefresh);
};
}, [currentUser]);
// Auto-überfällig: einmal pro Tag prüfen (verhindert Endlos-Loop, da save() data ändert). // Auto-überfällig: einmal pro Tag prüfen (verhindert Endlos-Loop, da save() data ändert).
const lastOverdueCheck = useRef(null); const lastOverdueCheck = useRef(null);
useEffect(() => { useEffect(() => {
if (loading) return;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
if (lastOverdueCheck.current === today) return; if (lastOverdueCheck.current === today) return;
lastOverdueCheck.current = today; lastOverdueCheck.current = today;
@@ -323,18 +454,73 @@ export default function App() {
); );
if (updated.some((inv, i) => inv.status !== data.invoices[i].status)) if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
save({ ...data, invoices: updated }); save({ ...data, invoices: updated });
}, [data, save]); }, [data, save, loading]);
if (isNewInstall && !data.settings.setupCompleted) { // Boot-Spinner während Initial-Load (Adapter ist async, auch wenn LocalStorage <50ms)
return <Setup onComplete={handleSetupComplete} />; if (loading) return <ViewFallback />;
// Erst-Screen einer frischen Installation: «Lokal oder Cloud?».
// Sichtbar solange der User die Wahl noch nicht getroffen hat UND es keine
// lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard.
// UpdateNotifier wird in allen Pre-Login-Screens mitgerendert, damit ein
// hängender Setup-Wizard sich via Auto-Update selbst befreien kann.
// Server-Modus (gehostete Web-GUI): nie die Lokal/Server-Wahl zeigen — die
// App ist fest an diesen Server gebunden. Nur die lokale DMG zeigt die Wahl.
const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1";
if (!isServerMode && !hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) {
return <><BackendChoice /><UpdateNotifier /></>;
} }
if (!localStorage.getItem("rapport_v0_5_migrated")) { // Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus
return <MigrationScreen data={data} onComplete={handleSetupComplete} />; // erfolgt Erst-Einrichtung über den Init-Dialog im Login.
if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) {
return <><Setup onComplete={handleSetupComplete} /><UpdateNotifier /></>;
} }
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
return <><MigrationScreen data={data} onComplete={handleSetupComplete} /><UpdateNotifier /></>;
}
// Passwort-Reset hat höchste Priorität — User kommt von Mail-Link
if (passwordRecovery) {
return <ResetPassword
onComplete={async (newPw) => {
try {
const { error } = await storage.client.auth.updateUser({ password: newPw });
if (error) return { ok: false, error: error.message };
return { ok: true };
} catch (e) {
return { ok: false, error: e.message || "Fehler" };
}
}}
onCancel={async () => {
await storage.client?.auth?.signOut?.();
setPasswordRecovery(false);
if (window.location.hash) window.location.hash = "";
}}
/>;
}
// Cloud + Instanz ist leer (0 Studios) → mehrseitiger Setup-Wizard.
// Cloud + Studios vorhanden → klassischer Login.
// UpdateNotifier wird hier auch gerendert, damit der Auto-Update-Check auch
// ohne Login läuft (sonst kommt man bei einem fehlerhaften Setup-Screen nie
// an ein neueres Build, das den Bug fixt).
if (!currentUser) { if (!currentUser) {
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.7" />; // Init-/Registrierungs-Screen NUR wenn der API-Call erfolgreich war UND
// wirklich 0 Studios lieferte. Bei !cloudUnreachable ausgeschlossen, dass
// ein Kong/API-Fehler (cloudStudios === null) hier fälschlich Init zeigt.
if (isCloudBackend && !cloudUnreachable && cloudStudios !== null && cloudStudios.length === 0) {
const cloudUrl = localStorage.getItem("rapport_cloud_url") || "";
return <>
<CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />
<UpdateNotifier />
</>;
}
return <>
<Login verifyLogin={verifyLogin} settings={data.settings} version="0.8.2" cloudUnreachable={cloudUnreachable} />
<UpdateNotifier />
</>;
} }
if (printContent) { if (printContent) {
@@ -607,8 +793,8 @@ export default function App() {
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<button onClick={() => setShowAbout(true)} style={{ background: "none", border: "none", padding: 0, color: "#555", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }} <button onClick={() => setShowAbout(true)} style={{ background: "none", border: "none", padding: 0, color: "#555", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }}
onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button> onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button>
<button onClick={() => { setChangelogVersion("0.7"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit" }} <button onClick={() => { setChangelogVersion("0.8.2"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit" }}
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.7</button> onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8.2</button>
</div> </div>
</div>} </div>}
@@ -652,7 +838,7 @@ export default function App() {
{view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} currentUser={currentUser} />} {view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} currentUser={currentUser} />}
{view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />} {view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />}
{view === "time" && <Time data={data} update={update} currentUser={currentUser} setPrintContent={setPrintContent} />} {view === "time" && <Time data={data} update={update} currentUser={currentUser} setPrintContent={setPrintContent} />}
{view === "quotes" && <Quotes data={data} update={update} setData={setData} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />} {view === "quotes" && <Quotes data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />}
{view === "dokumente" && <Documents data={data} setView={navigate} />} {view === "dokumente" && <Documents data={data} setView={navigate} />}
{view === "lieferscheine" && <DeliveryNotes data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />} {view === "lieferscheine" && <DeliveryNotes data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "protokolle" && <Protocols data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />} {view === "protokolle" && <Protocols data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
@@ -673,6 +859,31 @@ export default function App() {
{showChangelog && (() => { {showChangelog && (() => {
const CHANGELOGS = { const CHANGELOGS = {
"0.8.2": {
items: [
["Selbstheilung für hängende 0.8.0-Installationen", "Wer von 0.7 auf 0.8 geupdated hat und in den Cloud-Setup-Wizard geschoben wurde, kommt mit 0.8.2 automatisch zurück in seinen Lokal-Modus. Der Auto-Recovery-Code erkennt: Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Anmeldung → Cloud-Konfiguration wird zurückgenommen, alle Daten bleiben erhalten."],
["Auto-Update auch ohne Login", "Bisher prüfte Rapport erst nach dem Login auf Updates — wer in einem fehlerhaften Setup-Bildschirm hing, kam nicht an den Bugfix. Jetzt läuft der Update-Check auch im «Lokal oder Cloud»-Wizard, im Login-Screen und im Cloud-Setup."],
["Tauri ohne fest eingebaute Server-IP", "Die Desktop-App enthält keine vorkonfigurierte Cloud-Adresse mehr. Wer Cloud nutzen will, gibt die Server-Adresse beim Login aktiv ein — kein automatisches Vorausfüllen mit irrelevanten IPs."],
],
},
"0.8.1": {
items: [
["Update-Fix", "Behebt einen Fehler beim Upgrade von 0.7 auf 0.8: Lokal-Installationen wurden ungewollt in den Cloud-Modus geschoben und der Cloud-Setup-Wizard angezeigt, obwohl bereits lokale Daten vorhanden waren. Die App prüft jetzt vor einem automatischen Modus-Wechsel, ob lokale Daten existieren — und in Tauri-Installationen wird der Modus nie implizit gesetzt."],
],
},
"0.8": {
items: [
["Cloud-Variante", "Rapport kann jetzt nicht nur lokal, sondern auch auf einem eigenen Server (Supabase) laufen. Beim ersten Öffnen wählt man «Lokal» oder «Cloud»; beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User und Live-Sync zwischen Geräten."],
["Erst-Einrichtung als Wizard", "Cloud-Einrichtung als 3-Schritt-Assistent: Studio-Stammdaten, Admin-Account, optionale Buchhaltungsdaten (IBAN, MwSt, Stundensatz). Adresse und Bankverbindung sind optional und können später ergänzt werden."],
["Multi-Studio", "Ein Account kann mehrere Studios verwalten. Beim Anlegen eines neuen Studios lassen sich Personen (Kunden & Partner) aus bestehenden Studios übernehmen — Änderungen sind dann für alle verlinkten Studios sichtbar."],
["Live-Sync zwischen Browsern", "Änderungen in einem Browser (z.B. neue Pinnwand-Notiz) erscheinen in allen anderen offenen Rapport-Tabs ohne Reload. Funktioniert über Postgres-Realtime."],
["Mitarbeiter einladen", "Admins können Mitarbeiter direkt in den Einstellungen einladen: Email + Anzeigename + App-Rolle + temporäres Passwort. Eingeladene erhalten Zugangsdaten zum sofortigen Login."],
["Passwort-Reset", "Im Cloud-Modus gibt es einen «Passwort vergessen?»-Link auf der Anmeldeseite. Mit der hinterlegten Email wird ein Reset-Link per Mail verschickt."],
["Web-Version", "Wer keine Desktop-App installieren möchte, kann Rapport im Browser unter der Studio-Adresse nutzen (z.B. app.rapport.kgva.ch). Identische UI, gleiches Backend, kein Tauri nötig."],
["Sichere Datenhaltung pro Studio", "Daten verschiedener Studios sind auf Datenbank-Ebene strikt getrennt (Row-Level-Security). Kein Studio sieht je die Daten eines anderen."],
["Persönliche Zugangsdaten via Email", "Cloud-Anmeldung mit Email + Passwort (statt Benutzername). Lokal-Modus weiter wie gehabt mit Benutzername + Passwort."],
],
},
"0.7": { "0.7": {
items: [ items: [
["Automatische Updates", "Rapport prüft beim Start, ob eine neue Version unter git.kgva.ch verfügbar ist, und installiert sie auf Knopfdruck — kein manuelles DMG-Download mehr nötig. Updates lassen sich überspringen oder verschieben; Pakete werden vor der Installation per Signaturprüfung verifiziert."], ["Automatische Updates", "Rapport prüft beim Start, ob eine neue Version unter git.kgva.ch verfügbar ist, und installiert sie auf Knopfdruck — kein manuelles DMG-Download mehr nötig. Updates lassen sich überspringen oder verschieben; Pakete werden vor der Installation per Signaturprüfung verifiziert."],
@@ -737,7 +948,7 @@ export default function App() {
}, },
}; };
const versions = Object.keys(CHANGELOGS); const versions = Object.keys(CHANGELOGS);
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.7"]; const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.2"];
return ( return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}> <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}> <div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
@@ -766,7 +977,7 @@ export default function App() {
))} ))}
</div> </div>
<div style={{ padding: "12px 32px 24px" }}> <div style={{ padding: "12px 32px 24px" }}>
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.7"); }}> <button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8.2"); }}>
Schliessen Schliessen
</button> </button>
</div> </div>
@@ -781,7 +992,7 @@ export default function App() {
<div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}> <div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>ÜBER RAPPORT</div> <div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>ÜBER RAPPORT</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Rapport</div> <div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Rapport</div>
<div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.7 · Studio-Management für Architekturbüros</div> <div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8.2 · Studio-Management für Architekturbüros</div>
</div> </div>
<div style={{ padding: "20px 32px 8px" }}> <div style={{ padding: "20px 32px 8px" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div> <div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div>
+148
View File
@@ -0,0 +1,148 @@
// Storage-Adapter: Auswahl zwischen LocalStorage und Supabase (Cloud).
//
// Auswahl-Logik:
// localStorage["rapport_backend"] === "cloud" → SupabaseAdapter
// alles andere (default) → LocalStorageAdapter
//
// Umschalten: `localStorage.setItem("rapport_backend", "cloud")` (später UI-Toggle).
// Cloud braucht zusätzlich VITE_SUPABASE_URL und VITE_SUPABASE_ANON_KEY in .env.local.
// Fallback: wenn Cloud gewählt aber env fehlt, kommt LocalStorage zurück (mit Warning).
//
// Bewusst NICHT im Adapter:
// - UI-State (Dark Mode, Zoom, …) — per-Device, bleibt direkt in localStorage
// - Session/Auth — sessionStorage / Supabase-Auth-eigenes Storage
// - Migrations — siehe migrations.js, läuft nach dem load auf den Rohdaten
import { STORAGE_KEY } from "../constants.js";
import { SupabaseAdapter } from "./supabase-adapter.js";
export class LocalStorageAdapter {
async hasExistingData() {
return !!localStorage.getItem(STORAGE_KEY);
}
async load() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
return JSON.parse(stored);
} catch {
return null;
}
}
async save(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error("LocalStorage save failed:", e);
throw e;
}
}
async clear() {
localStorage.removeItem(STORAGE_KEY);
}
}
// SERVER-MODUS: dieser Build ist die gehostete Web-GUI eines bestimmten
// Servers (gesetzt via VITE_SERVER_MODE=1 im Dockerfile.app). Dann ist die App
// FEST an diesen einen Server gebunden — keine Lokal/Server-Wahl, kein Wechsel
// der Server-Adresse, kein Verbinden auf andere Instanzen. Nur der Login auf
// genau diesem Server. Die lokale DMG (Tauri) setzt diese Flag NIE und behält
// die volle Wahl (Lokal / beliebige Server-IP).
const _isTauri = typeof window !== "undefined" && !!window.__TAURI_INTERNALS__;
export const isServerMode = import.meta.env.VITE_SERVER_MODE === "1" && !_isTauri;
function createAdapter() {
const isTauri = _isTauri;
// Build-time-URL nur für Web-Deploy gültig. Tauri-Builds ignorieren den
// eingebrannten Wert — Desktop-User geben die Server-URL aktiv ein.
// Der Anon-Key bleibt aus dem Build, weil er pro Cloud-Instanz konstant ist
// (kein User-Geheimnis, sondern Public-Konfig).
const envUrl = isTauri ? null : import.meta.env.VITE_SUPABASE_URL;
const envKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// ── Server-Modus: fest auf diesen Server, ohne Wahlmöglichkeit ───────────
if (isServerMode) {
if (!envUrl || !envKey) {
console.error("VITE_SERVER_MODE=1, aber VITE_SUPABASE_URL/ANON_KEY fehlen — Fehlkonfiguration des Web-Builds.");
return new LocalStorageAdapter();
}
// localStorage konsistent halten (Login liest cloud_url für listStudios),
// aber der User kann nichts davon ändern — die Werte kommen aus dem Build.
if (typeof localStorage !== "undefined") {
localStorage.setItem("rapport_backend", "cloud");
localStorage.setItem("rapport_backend_chosen", "1");
localStorage.setItem("rapport_cloud_url", envUrl.replace(/\/+$/, ""));
}
return new SupabaseAdapter(envUrl, envKey);
}
if (typeof localStorage !== "undefined") {
// ── Auto-Recovery für 0.8.0-Upgrade-Bug ──────────────────────────────
// 0.8.0 hat Cloud-Modus ungewollt gesetzt bei Lokal-Usern (siehe 0.8.1
// CHANGELOG). 0.8.1 verhindert das nur prospektiv, behebt aber den
// bestehenden Cloud-State nicht. Hier räumen wir nachträglich auf:
// Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Session
// → Cloud-State löschen, User landet im BackendChoice oder Lokal-Modus.
const RECOVERY_MARKER = "rapport_080_recovery";
const backend = localStorage.getItem("rapport_backend");
const hasLocalData = !!localStorage.getItem(STORAGE_KEY);
const hasCloudSession = !!localStorage.getItem("rapport_supabase_session");
if (backend === "cloud" && hasLocalData && !hasCloudSession && !localStorage.getItem(RECOVERY_MARKER)) {
console.warn("Auto-Recovery: Cloud-Modus gesetzt, aber Lokal-Daten vorhanden und kein Cloud-Login — räume Cloud-State auf.");
localStorage.removeItem("rapport_backend");
localStorage.removeItem("rapport_backend_chosen");
localStorage.removeItem("rapport_cloud_url");
localStorage.setItem(RECOVERY_MARKER, "1");
}
// ── Auto-Cloud-Default für Web-Deploy ────────────────────────────────
// Nur wenn keine lokalen Daten existieren und der Browser auf einer
// konfigurierten Web-Instanz landet (envUrl aus build).
if (import.meta.env.PROD && envUrl && !localStorage.getItem("rapport_backend_chosen") && !hasLocalData) {
localStorage.setItem("rapport_backend_chosen", "1");
localStorage.setItem("rapport_backend", "cloud");
if (!localStorage.getItem("rapport_cloud_url")) {
localStorage.setItem("rapport_cloud_url", envUrl.replace(/\/+$/, ""));
}
}
}
const backend = (typeof localStorage !== "undefined"
&& localStorage.getItem("rapport_backend")) || "local";
if (backend === "cloud") {
// URL kommt bevorzugt aus localStorage (vom User eingegeben). Tauri hat
// gar keine env-URL; Web-Build hat sie ggf. als Fallback.
const url = (typeof localStorage !== "undefined" && localStorage.getItem("rapport_cloud_url")) || envUrl;
if (!url || !envKey) {
console.warn("rapport_backend=cloud, aber URL oder ANON_KEY fehlen — Fallback auf LocalStorage.");
return new LocalStorageAdapter();
}
console.info("Storage-Adapter: SupabaseAdapter aktiv (URL:", url + ")");
return new SupabaseAdapter(url, envKey);
}
return new LocalStorageAdapter();
}
// Singleton — wird beim Modul-Load gewählt.
// Switch zur Laufzeit erfordert (vorerst) einen App-Reload.
export const storage = createAdapter();
export const isCloudBackend = storage instanceof SupabaseAdapter;
// Für Dev-Tests im Browser: window.__rapport.storage und ein Helper, um die
// Cloud-Verbindung ohne UI zu testen.
if (typeof window !== "undefined") {
window.__rapport = window.__rapport || {};
window.__rapport.storage = storage;
window.__rapport.useCloud = () => {
localStorage.setItem("rapport_backend", "cloud");
location.reload();
};
window.__rapport.useLocal = () => {
localStorage.setItem("rapport_backend", "local");
location.reload();
};
}
+92
View File
@@ -0,0 +1,92 @@
// State-Migrations für geladene `data`-Objekte.
// Extrahiert aus App.jsx, damit der Initial-Load über den (async) Adapter
// laufen kann und die Migrations sowohl auf Local- als auch Cloud-Daten
// dieselbe Form anwenden.
//
// Reine Funktion: nimmt geparste Rohdaten + defaultData entgegen, gibt das
// migrierte `data`-Objekt zurück. Keine Side-Effects, kein Storage-Zugriff.
import { migrateDashboardLayout } from "../utils.js";
export function applyMigrations(parsed, defaultData) {
let merged = {
...defaultData,
...parsed,
settings: { ...defaultData.settings, ...parsed.settings },
};
// Migrate: clients[] + contacts[] → persons[]
if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) {
const idMap = {};
const persons = [];
const usedContactIds = new Set();
for (const c of merged.clients || []) {
const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId);
persons.push({
...c,
isAuftraggeber: true,
isPartner: !!linked,
type: c.type || linked?.type || "",
note: c.note || linked?.note || "",
honorarOffers: c.honorarOffers || linked?.honorarOffers || [],
contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []),
linkedContactId: undefined,
linkedClientId: undefined,
});
if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; }
}
for (const ct of merged.contacts || []) {
if (usedContactIds.has(ct.id)) continue;
persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined });
}
const remapProjects = (merged.projects || []).map(p => ({
...p,
projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })),
}));
const remapProtocols = (merged.protocols || []).map(p => ({
...p,
entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })),
}));
merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined };
}
// Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz)
const allQuotes = merged.quotes || [];
const projects = (merged.projects || []).map(p => {
if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) {
const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) {
return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 };
}
}
return p;
});
// Migrate: add r-projektleiter if missing, seed dashboardTemplateId from defaultData
const roleDefMap = (defaultData.appRoles || []).reduce((acc, r) => { acc[r.id] = r; return acc; }, {});
const roles = (merged.appRoles || defaultData.appRoles).map(r => ({
...r,
dashboardTemplateId: r.dashboardTemplateId || roleDefMap[r.id]?.dashboardTemplateId || null,
permissions: (() => {
let perms = r.permissions;
if (perms && r.id === "r-projektleiter" && !perms.includes("mitarbeiter")) perms = [...perms, "mitarbeiter"];
if (perms && !perms.includes("settings")) perms = [...perms, "settings"];
return perms;
})(),
}));
if (!roles.find(r => r.id === "r-projektleiter") && roleDefMap["r-projektleiter"]) {
const adminIdx = roles.findIndex(r => r.id === "r-admin");
roles.splice(adminIdx + 1, 0, roleDefMap["r-projektleiter"]);
}
// Migrate user-level dashboardWidgets to Row[] format
const users = (merged.users || []).map(u => ({
...u,
dashboardWidgets: u.dashboardWidgets ? migrateDashboardLayout(u.dashboardWidgets) : undefined,
}));
// Ensure dashboardTemplates exist (old data won't have them)
const dashboardTemplates = merged.dashboardTemplates?.length ? merged.dashboardTemplates : defaultData.dashboardTemplates;
return { ...merged, projects, appRoles: roles, users, dashboardTemplates };
}
+487
View File
@@ -0,0 +1,487 @@
// SupabaseAdapter — Cloud-Variante des Storage-Adapters.
//
// Phase 3b.1 (jetzt): Skelett mit Connection-Setup und Auth-Check.
// load/save/clear werfen `NotImplementedError`.
// Phase 3b.2 (next): load() — alle Tabellen lesen, zu `data`-Shape zusammensetzen.
// Phase 3b.3: save() — `data` zerlegen, in Tabellen schreiben.
// Phase 3b.4: Auth-Flow (signIn/signUp), Studio-Wahl bei Multi-Studio-User.
//
// Multi-Tenant: jede Query filtert nach `studio_id` (kommt nach Login aus
// studio_members). Vor erfolgreichem Login kann nichts geladen werden.
import { createClient } from "@supabase/supabase-js";
import { fromDB, toDB } from "./supabase-mappers.js";
class NotImplementedError extends Error {
constructor(method) {
super(`SupabaseAdapter.${method}() — wird in einer späteren Phase implementiert.`);
this.name = "NotImplementedError";
}
}
export class SupabaseAdapter {
constructor(url, anonKey) {
if (!url || !anonKey) {
throw new Error("SupabaseAdapter: URL und Anon-Key sind erforderlich.");
}
this.url = url;
this.anonKey = anonKey;
this.client = createClient(url, anonKey, {
auth: {
// Session in localStorage persistieren (wie LocalStorage Adapter),
// damit der User nach Reload nicht erneut einloggen muss.
persistSession: true,
autoRefreshToken: true,
storageKey: "rapport_supabase_session",
},
});
this._studioId = null;
}
// Wird nach erfolgreichem Login gesetzt — bei Multi-Studio-Usern wählt
// der User explizit, in welchem Studio er gerade arbeitet.
setStudioId(studioId) {
this._studioId = studioId;
}
// Diagnose: prüft, ob die Cloud erreichbar ist (auth-Endpoint antwortet).
// Funktioniert auch ohne eingeloggten User.
async testConnection() {
try {
const { error } = await this.client.auth.getSession();
if (error) return { ok: false, error: error.message };
return { ok: true };
} catch (e) {
return { ok: false, error: e.message || String(e) };
}
}
// Login per Email + Passwort. Liefert { user, profile, studios } oder null
// bei falschen Credentials. Studios ist die Liste aller studio_members des
// Users — Caller wählt das aktive aus (in Phase 3b.5 mit UI-Dropdown).
async signIn(email, password) {
const { data: authData, error: authErr } = await this.client.auth.signInWithPassword({ email, password });
if (authErr || !authData?.user) return null;
const userId = authData.user.id;
const [{ data: profile }, { data: memberships }] = await Promise.all([
this.client.from("profiles").select("*").eq("id", userId).maybeSingle(),
this.client.from("studio_members")
.select("studio_id, app_role_id, studios(name, slug)")
.eq("user_id", userId)
.eq("active", true),
]);
return {
user: authData.user,
profile: profile || null,
studios: memberships || [],
};
}
async signOut() {
this.unsubscribeFromChanges();
await this.client.auth.signOut();
this._studioId = null;
}
// Passwort-Reset anfordern. Supabase Auth verschickt eine Mail mit einem
// Reset-Link, der auf `redirectTo` zurückführt. Wichtig: KEIN eigenes Hash-
// Fragment in redirectTo — Supabase appended sein eigenes (`#access_token=
// ...&type=recovery`), und zwei `#` brechen die URL.
async requestPasswordReset(email) {
const redirectTo = (typeof window !== "undefined")
? `${window.location.origin}${window.location.pathname}`
: undefined;
const { error } = await this.client.auth.resetPasswordForEmail(email, { redirectTo });
if (error) return { ok: false, error: error.message };
return { ok: true };
}
// Sign-Up für brandneue Cloud-Accounts. Selfhosted-Supabase hat per default
// `enable_confirmations = false`, also gibt's nach signUp direkt eine Session.
// Frontend muss anschließend `createStudio()` aufrufen, damit der User ein
// Studio hat (sonst hängt er im "0 Studios"-Limbo).
async signUp(email, password) {
const { data, error } = await this.client.auth.signUp({ email, password });
if (error || !data?.user) {
return { ok: false, error: error?.message || "signUp failed" };
}
return { ok: true, user: data.user };
}
// Anlegen / Aktualisieren des eigenen Profils. Pflicht vor createStudio,
// sonst zeigt data.users[] keinen displayName.
async ensureProfile(username, displayName) {
const { error } = await this.client.rpc("ensure_profile", {
p_username: username,
p_display_name: displayName,
});
if (error) throw new Error("ensureProfile: " + error.message);
}
// Öffentliche Liste aller Studios auf dieser Supabase-Instanz — wird vom
// Login-Screen genutzt, um den Studio-Dropdown vor Email+Passwort zu füllen.
// Kein Auth nötig (RPC läuft als SECURITY DEFINER).
async listStudios() {
const { data, error } = await this.client.rpc("list_studios");
// WICHTIG: Fehler NICHT zu [] verschlucken. Ein leeres Array bedeutet
// "Instanz hat 0 Studios" → das Frontend zeigt dann den Registrierungs-/
// Init-Screen. Ein Netzwerk-/API-Fehler (Kong down, Port 8000 nicht
// erreichbar) ist aber NICHT dasselbe wie "leer" — würden wir hier []
// zurückgeben, landet ein eingeloggter User nach Reload fälschlich im
// Init-Flow. Daher: werfen und den Caller entscheiden lassen.
if (error) throw new Error("listStudios: " + error.message);
return data || [];
}
// Legt ein neues Studio an und macht den aktuellen User zum Admin.
// Optional: `sharePersonsFrom` ist eine Liste von Quell-Studio-IDs, deren
// Personen ins neue Studio mit übernommen werden (siehe RPC-Doc in 0007).
// Liefert die neue studio_id zurück.
async createStudio(name, slug, sharePersonsFrom = []) {
const { data, error } = await this.client.rpc("create_studio_with_admin", {
p_name: name,
p_slug: slug,
p_share_persons_from: sharePersonsFrom,
});
if (error) throw new Error("createStudio: " + error.message);
return data; // uuid
}
// Mitarbeiter ins aktuelle Studio einladen — Admin-Aktion.
// 1) signUp mit temporärem Client (kein Session-Persist, damit der Admin
// nicht selbst "ausgeloggt" und auf den Neuen umgeschaltet wird).
// 2) attach_user_to_studio RPC: legt Profile + Membership an. Prüft
// serverseitig, dass der Caller Admin im Ziel-Studio ist.
// Liefert das temp-Passwort zurück, damit der Admin es dem Mitarbeiter
// weitergeben kann (mündlich, separater Kanal etc.).
async inviteMember(email, tempPassword, displayName, appRoleId = "r-mitarbeiter") {
if (!this._studioId) return { ok: false, error: "Kein aktives Studio." };
// Temporärer Client für den signUp — eigene Session bleibt intakt
const tempClient = createClient(this.url, this.anonKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: signUpRes, error: signErr } = await tempClient.auth.signUp({
email, password: tempPassword,
});
if (signErr || !signUpRes?.user) {
return { ok: false, error: signErr?.message || "signUp failed" };
}
const username = (email.split("@")[0] || "user").replace(/[^a-zA-Z0-9._-]/g, "");
const { error: attachErr } = await this.client.rpc("attach_user_to_studio", {
p_user_id: signUpRes.user.id,
p_studio_id: this._studioId,
p_app_role_id: appRoleId,
p_username: username,
p_display_name: displayName,
});
if (attachErr) return { ok: false, error: attachErr.message };
return { ok: true, userId: signUpRes.user.id };
}
// Liefert die Studios, in denen der aktuelle User Mitglied ist —
// gebraucht im Settings-Cloud-Tab (Studio-Switcher + Sharing-Auswahl).
async myStudios() {
const { data: sess } = await this.client.auth.getSession();
const userId = sess?.session?.user?.id;
if (!userId) return [];
const { data, error } = await this.client.from("studio_members")
.select("studio_id, app_role_id, studios(name, slug)")
.eq("user_id", userId)
.eq("active", true);
if (error) {
console.error("myStudios:", error.message);
return [];
}
return (data || []).map(m => ({
id: m.studio_id,
name: m.studios?.name,
slug: m.studios?.slug,
appRoleId: m.app_role_id,
}));
}
// Realtime: lauscht auf alle DB-Änderungen im aktuellen Studio und ruft
// `onChange()` (debounced vom Caller). Eine Subscription deckt alle Tabellen
// im public-Schema ab — wir filtern nicht weiter, weil die postgres_changes-
// API kein einfaches Tenant-Filter über Joins erlaubt. Stattdessen vertraut
// der Caller darauf, dass load() nur die studio_eigenen Daten zurückgibt
// (was via RLS garantiert ist).
subscribeToChanges(onChange) {
if (this._channel) return;
this._channel = this.client
.channel(`rapport-studio-${this._studioId}`)
.on("postgres_changes", { event: "*", schema: "public" }, () => {
try { onChange(); } catch (e) { console.error("onChange handler:", e); }
})
.subscribe();
}
unsubscribeFromChanges() {
if (this._channel) {
this.client.removeChannel(this._channel);
this._channel = null;
}
}
async hasExistingData() {
if (!this._studioId) return false;
const { count, error } = await this.client
.from("studios")
.select("id", { count: "exact", head: true })
.eq("id", this._studioId);
if (error) {
console.error("hasExistingData failed:", error);
return false;
}
return (count || 0) > 0;
}
// Lädt den vollständigen `data`-Snapshot eines Studios aus der Cloud.
// Sub-Tabellen (invoice_reminders, project_quote_links, delivery_note_items)
// werden via Inner-Join nach studio_id gefiltert, damit RLS-Konsistenz wahrt.
async load() {
if (!this._studioId) {
throw new Error("SupabaseAdapter.load: studio_id nicht gesetzt — setStudioId() nach Login.");
}
const sid = this._studioId;
const c = this.client;
const responses = await Promise.all([
c.from("studio_settings").select("*").eq("studio_id", sid).maybeSingle(),
c.from("studio_roles").select("*").eq("studio_id", sid).order("sort"),
// Personen kommen via RPC, weil geteilte (global, studio_id=NULL) nur über
// person_studio_links sichtbar werden — direkter studio_id-Filter würde sie verlieren.
c.rpc("load_persons_for_studio", { p_studio_id: sid }),
c.from("projects").select("*").eq("studio_id", sid).order("number"),
c.from("project_quote_links").select("*, projects!inner(studio_id)").eq("projects.studio_id", sid),
c.from("quotes").select("*").eq("studio_id", sid).order("number"),
c.from("invoices").select("*").eq("studio_id", sid).order("number"),
c.from("invoice_reminders").select("*, invoices!inner(studio_id)").eq("invoices.studio_id", sid),
c.from("time_entries").select("*").eq("studio_id", sid).order("date"),
c.from("expenses").select("*").eq("studio_id", sid).order("date"),
c.from("internal_expenses").select("*").eq("studio_id", sid).order("date"),
c.from("employees").select("*").eq("studio_id", sid).order("name"),
c.from("absences").select("*").eq("studio_id", sid),
c.from("vacation_entries").select("*").eq("studio_id", sid),
c.from("payroll_entries").select("*").eq("studio_id", sid),
c.from("overtime_closings").select("*").eq("studio_id", sid),
c.from("holidays").select("*").eq("studio_id", sid),
c.from("absence_types").select("*").eq("studio_id", sid),
c.from("letter_templates").select("*").eq("studio_id", sid),
c.from("app_roles").select("*").eq("studio_id", sid),
c.from("dashboard_templates").select("*").eq("studio_id", sid),
c.from("protocols").select("*").eq("studio_id", sid),
c.from("delivery_notes").select("*").eq("studio_id", sid).order("number"),
c.from("delivery_note_items").select("*, delivery_notes!inner(studio_id)").eq("delivery_notes.studio_id", sid),
c.from("blog_posts").select("*").eq("studio_id", sid).order("created_at", { ascending: false }),
// studio_members → wird zu data.users[] (zusammen mit profiles unten)
c.from("studio_members")
.select("user_id, app_role_id")
.eq("studio_id", sid)
.eq("active", true),
]);
// Profile-Lookup separat: PostgREST kann den Join über auth.users nicht inferren.
const memberIds = (responses[responses.length - 1].data || []).map(m => m.user_id);
let profilesById = {};
if (memberIds.length) {
const { data: profileRows, error: profErr } = await c.from("profiles")
.select("id, username, display_name")
.in("id", memberIds);
if (profErr) throw new Error("SupabaseAdapter.load profiles: " + profErr.message);
profilesById = Object.fromEntries((profileRows || []).map(p => [p.id, p]));
}
for (const r of responses) {
if (r.error) {
throw new Error("SupabaseAdapter.load: " + r.error.message);
}
}
const [
settingsR, rolesR, personsR, projectsR, quoteLinksR, quotesR,
invoicesR, remindersR, timeEntriesR, expensesR, internalExpensesR,
employeesR, absencesR, vacationR, payrollR, overtimeR, holidaysR,
absenceTypesR, letterTemplatesR, appRolesR, dashboardTemplatesR,
protocolsR, deliveryNotesR, deliveryNoteItemsR, blogPostsR,
membersR,
] = responses;
return {
settings: settingsR.data
? fromDB.studioSettings(settingsR.data, rolesR.data || [])
: undefined,
persons: (personsR.data || []).map(fromDB.person),
projects: (projectsR.data || []).map(p => fromDB.project(p, quoteLinksR.data || [])),
quotes: (quotesR.data || []).map(fromDB.quote),
invoices: (invoicesR.data || []).map(i => fromDB.invoice(i, remindersR.data || [])),
timeEntries: (timeEntriesR.data || []).map(fromDB.timeEntry),
expenses: (expensesR.data || []).map(fromDB.expense),
internalExpenses: (internalExpensesR.data || []).map(fromDB.internalExpense),
employees: (employeesR.data || []).map(fromDB.employee),
absences: (absencesR.data || []).map(fromDB.absence),
ferienEntries: (vacationR.data || []).map(fromDB.vacationEntry),
lohnEntries: (payrollR.data || []).map(fromDB.payrollEntry),
uberstundenAbschluss: (overtimeR.data || []).map(fromDB.overtimeClosing),
feiertage: (holidaysR.data || []).map(fromDB.holiday),
absenzTypes: (absenceTypesR.data || []).map(fromDB.absenceType),
letterTemplates: (letterTemplatesR.data || []).map(fromDB.letterTemplate),
appRoles: (appRolesR.data || []).map(fromDB.appRole),
dashboardTemplates: (dashboardTemplatesR.data || []).map(fromDB.dashboardTemplate),
protocols: (protocolsR.data || []).map(fromDB.protocol),
deliveryNotes: (deliveryNotesR.data || []).map(dn => fromDB.deliveryNote(dn, deliveryNoteItemsR.data || [])),
blogPosts: (blogPostsR.data || []).map(fromDB.blogPost),
users: (membersR.data || []).map(m => {
const p = profilesById[m.user_id] || {};
return {
id: m.user_id,
username: p.username || "",
displayName: p.display_name || p.username || "",
appRoleId: m.app_role_id,
role: m.app_role_id === "r-admin" ? "admin" : "user",
};
}),
};
}
// Schreibt den Snapshot in die Cloud. Queue-Pattern: pro Zeitpunkt läuft
// höchstens ein Write — neue save()-Calls werden in `_pendingData` gesammelt
// und nach dem aktuellen Write zu einem einzigen weiteren Write zusammen-
// geführt (coalescing). Damit gibt es keine Race-Conditions und kein
// verzögertes Schreiben, das bei einem Page-Reload verloren gehen könnte.
//
// Strategie: "Full Replace per studio_id" — UPSERT für Konfig, UPSERT +
// DELETE-not-in-snapshot für Daten, DELETE+INSERT für Sub-Tables (reminders/
// items/quote-links). Kein echter Diff, last-write-wins. Reicht für Single-
// User-Studios.
async save(data) {
if (!this._studioId) {
throw new Error("SupabaseAdapter.save: studio_id nicht gesetzt.");
}
this._pendingData = data;
if (this._currentWrite) return this._currentWrite;
this._currentWrite = (async () => {
try {
while (this._pendingData) {
const next = this._pendingData;
this._pendingData = null;
await this._writeSnapshot(next);
}
} finally {
this._currentWrite = null;
}
})();
return this._currentWrite;
}
async _writeSnapshot(data) {
const sid = this._studioId;
const c = this.client;
// ── 1. Studio-Settings (Singleton) und Konfig-Tabellen (UPSERT-only,
// kein Cleanup wegen referentieller Bindungen wie studio_members.app_role_id)
const configOps = [
c.from("studio_settings").upsert(toDB.studioSettings(data.settings || {}, sid), { onConflict: "studio_id" }),
];
if (data.settings?.roles?.length)
configOps.push(c.from("studio_roles").upsert(toDB.studioRoles(data.settings.roles, sid), { onConflict: "studio_id,id" }));
if (data.appRoles?.length)
configOps.push(c.from("app_roles").upsert((data.appRoles || []).map(r => toDB.appRole(r, sid)), { onConflict: "studio_id,id" }));
if (data.dashboardTemplates?.length)
configOps.push(c.from("dashboard_templates").upsert((data.dashboardTemplates || []).map(d => toDB.dashboardTemplate(d, sid)), { onConflict: "studio_id,id" }));
if (data.absenzTypes?.length)
configOps.push(c.from("absence_types").upsert((data.absenzTypes || []).map(t => toDB.absenceType(t, sid)), { onConflict: "studio_id,id" }));
if (data.letterTemplates?.length)
configOps.push(c.from("letter_templates").upsert((data.letterTemplates || []).map(t => toDB.letterTemplate(t, sid)), { onConflict: "studio_id,id" }));
if (data.feiertage?.length)
configOps.push(c.from("holidays").upsert((data.feiertage || []).map(h => toDB.holiday(h, sid)), { onConflict: "studio_id,date" }));
await this._allOk(configOps, "config");
// ── 2. Daten-Parents (UPSERT + DELETE-not-in-snapshot)
await Promise.all([
this._syncTable("persons", sid, (data.persons || []).map(p => toDB.person(p, sid))),
this._syncTable("employees", sid, (data.employees || []).map(e => toDB.employee(e, sid))),
]);
// ── 3. Daten-Mid-Level (referenzieren persons/employees)
await Promise.all([
this._syncTable("projects", sid, (data.projects || []).map(p => toDB.project(p, sid))),
this._syncTable("quotes", sid, (data.quotes || []).map(q => toDB.quote(q, sid))),
this._syncTable("absences", sid, (data.absences || []).map(a => toDB.absence(a, sid))),
this._syncTable("vacation_entries", sid, (data.ferienEntries || []).map(v => toDB.vacationEntry(v, sid))),
this._syncTable("payroll_entries", sid, (data.lohnEntries || []).map(p => toDB.payrollEntry(p, sid))),
this._syncTable("overtime_closings", sid, (data.uberstundenAbschluss || []).map(o => toDB.overtimeClosing(o, sid))),
]);
// ── 4. Daten-Children (referenzieren projects/quotes)
await Promise.all([
this._syncTable("invoices", sid, (data.invoices || []).map(i => toDB.invoice(i, sid))),
this._syncTable("time_entries", sid, (data.timeEntries || []).map(t => toDB.timeEntry(t, sid))),
this._syncTable("expenses", sid, (data.expenses || []).map(e => toDB.expense(e, sid))),
this._syncTable("internal_expenses", sid, (data.internalExpenses || []).map(e => toDB.internalExpense(e, sid))),
this._syncTable("protocols", sid, (data.protocols || []).map(p => toDB.protocol(p, sid))),
this._syncTable("delivery_notes", sid, (data.deliveryNotes || []).map(d => toDB.deliveryNote(d, sid))),
this._syncTable("blog_posts", sid, (data.blogPosts || []).map(b => toDB.blogPost(b, sid))),
]);
// ── 5. Sub-Tables (replace per parent — Inhalt kommt direkt aus den Parent-Rows)
await Promise.all([
this._replaceSubTable("project_quote_links", "project_id",
(data.projects || []).map(p => p.id),
toDB.projectQuoteLinks(data.projects || [])),
this._replaceSubTable("invoice_reminders", "invoice_id",
(data.invoices || []).map(i => i.id),
toDB.invoiceReminders(data.invoices || [])),
this._replaceSubTable("delivery_note_items", "delivery_note_id",
(data.deliveryNotes || []).map(d => d.id),
toDB.deliveryNoteItems(data.deliveryNotes || [])),
]);
}
// UPSERT alle rows + DELETE was nicht mehr im snapshot ist (gleicher studio_id-Scope)
async _syncTable(table, sid, rows) {
const ids = rows.map(r => r.id).filter(Boolean);
const ops = [];
if (rows.length) {
ops.push(this.client.from(table).upsert(rows));
}
let delQ = this.client.from(table).delete().eq("studio_id", sid);
if (ids.length) {
delQ = delQ.not("id", "in", `(${ids.join(",")})`);
}
ops.push(delQ);
await this._allOk(ops, table);
}
// Sub-Tables: keine eigene studio_id, Filter über Parent-ID-Liste.
// Strategy: DELETE alle für (parent_id ∈ parentIds), dann INSERT die neuen rows.
async _replaceSubTable(table, parentField, parentIds, rows) {
if (parentIds.length === 0 && rows.length === 0) return;
const ops = [];
if (parentIds.length) {
ops.push(this.client.from(table).delete().in(parentField, parentIds));
}
if (rows.length) {
// Insert kommt nach Delete (Reihenfolge wichtig) — daher sequentiell
await this._allOk(ops, table);
const { error } = await this.client.from(table).insert(rows);
if (error) throw new Error(`INSERT ${table}: ${error.message}`);
return;
}
await this._allOk(ops, table);
}
async _allOk(ops, label) {
const results = await Promise.all(ops);
for (const r of results) {
if (r.error) throw new Error(`${label}: ${r.error.message}`);
}
}
async clear() { throw new NotImplementedError("clear"); }
}
+649
View File
@@ -0,0 +1,649 @@
// Mapping zwischen Postgres-Rows und dem Frontend-`data`-Shape.
//
// Konventionen:
// - DB ist snake_case, Frontend camelCase. Übersetzung explizit pro Entity.
// - Postgres `numeric` kommt als String via JSON-API zurück → `num()` wandelt.
// - JSONB-Spalten (settings.formats, settings.ui, settings.page_margins,
// protocols.participants, projects.positions, …) werden direkt in das
// Frontend-Objekt gespreaded.
// - Zirkuläre / verschachtelte Sub-Entities (invoice.reminders, project.linkedQuotes,
// delivery_note.items) bekommen eine Liste der Parent-IDs übergeben und
// filtern selbst zur richtigen Zeile.
const num = (v) => v == null ? null : Number(v);
// Reverse-Mapping: Settings flach → JSONB-Sub-Objekte zerlegen.
// Diese Felder gehören in settings.formats:
const FORMAT_KEYS = ["projectNumberFormat", "invoiceNumberFormat", "protokollNumberFormat", "pdfNameFormat"];
// in settings.page_margins:
const PAGE_MARGIN_KEYS = ["pageMarginTop", "pageMarginBottom", "pageMarginLeft", "pageMarginRight"];
// in settings.ui:
const UI_KEYS = ["autoPrint", "logoSize", "qrNewPage", "expenseCategories", "internalExpenseCategories"];
function pickKeys(obj, keys) {
const out = {};
for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k];
return out;
}
export const fromDB = {
studio: (r) => ({ id: r.id, name: r.name, slug: r.slug }),
// Settings sind eine 1:1-Tabelle + studio_roles als Sub-Liste.
// JSONB-Felder werden flach in `settings` gespreaded, damit das Frontend
// weiter mit `settings.projectNumberFormat`, `settings.pageMarginTop` etc.
// arbeiten kann.
studioSettings: (row, roles = []) => ({
setupCompleted: row.setup_completed,
name: row.name,
address: row.address,
street: row.street, zip: row.zip, city: row.city, country: row.country,
email: row.email, phone: row.phone,
iban: row.iban, ibanType: row.iban_type,
mwst: row.mwst_nr, mwstRate: num(row.mwst_rate),
defaultHourlyRate: num(row.default_hourly_rate),
defaultWochenstunden: num(row.default_wochenstunden),
defaultFerienWochen: num(row.default_ferien_wochen),
closedMonths: row.closed_months || [],
blockMaiTag: row.block_mai_tag,
protokollTypeAbbreviations: row.protokoll_type_abbr || {},
logoUrl: row.logo_url, // Pfad in Supabase Storage (statt Base64)
...(row.formats || {}), // projectNumberFormat, invoiceNumberFormat, protokollNumberFormat, pdfNameFormat
...(row.page_margins || {}), // pageMarginTop/Bottom/Left/Right
...(row.ui || {}), // autoPrint, logoSize, qrNewPage, expenseCategories, internalExpenseCategories
roles: roles.map(fromDB.studioRole),
}),
studioRole: (r) => ({ id: r.id, label: r.label, rate: num(r.rate) }),
person: (r) => ({
id: r.id,
isShared: r.studio_id === null, // global = studio_id NULL + Sichtbarkeit via person_studio_links
name: r.name,
type: r.person_type,
isAuftraggeber: r.is_auftraggeber,
isPartner: r.is_partner,
street: r.street, zip: r.zip, city: r.city, country: r.country,
email: r.email, phone: r.phone, website: r.website,
note: r.note,
contacts: r.contacts || [],
honorarOffers: r.honorar_offers || [],
}),
project: (r, allQuoteLinks = []) => ({
id: r.id,
number: r.number,
name: r.name,
clientId: r.client_id,
category: r.category,
billingType: r.billing_type,
hourlyRate: num(r.hourly_rate),
budget: num(r.budget),
budgetHours: num(r.budget_hours),
status: r.status,
description: r.description,
startDate: r.start_date,
enabledPhases: r.enabled_phases || [],
positions: r.positions || [],
customPhases: r.custom_phases || [],
projectContacts: r.project_contacts || [],
internalMembers: r.internal_members || [],
linkedQuotes: allQuoteLinks
.filter(l => l.project_id === r.id)
.map(l => ({ quoteId: l.quote_id, role: l.role })),
}),
quote: (r) => ({
id: r.id,
number: r.number,
clientId: r.client_id,
projectId: r.project_id,
projectName: r.project_name,
date: r.date,
validUntil: r.valid_until,
mode: r.mode,
mwst: r.mwst,
notes: r.notes,
status: r.status,
sia: r.sia_config,
manualPhases: r.manual_phases,
freeItems: r.free_items,
quoteRoles: r.quote_roles,
}),
invoice: (r, allReminders = []) => ({
id: r.id,
number: r.number,
clientId: r.client_id,
contactId: r.contact_id,
projectId: r.project_id,
quoteId: r.quote_id,
date: r.date,
dueDate: r.due_date,
sentDate: r.sent_date,
paidDate: r.paid_date,
items: r.items || [],
mwst: r.mwst,
mwstRate: num(r.mwst_rate),
notes: r.notes,
status: r.status,
invoiceKind: r.invoice_kind,
discountType: r.discount_type,
discountValue: num(r.discount_value),
discountLabel: r.discount_label,
entrySelections: r.entry_selections || {},
qrReference: r.qr_reference,
reminders: allReminders
.filter(rem => rem.invoice_id === r.id)
.sort((a, b) => (a.nr || 0) - (b.nr || 0))
.map(rem => ({
nr: rem.nr,
date: rem.date,
sentDate: rem.sent_date,
daysPast: rem.days_past,
note: rem.note,
})),
}),
expense: (r) => ({
id: r.id,
employeeId: r.employee_id,
projectId: r.project_id,
date: r.date,
category: r.category,
description: r.description,
amount: num(r.amount),
mwstRate: num(r.mwst_rate),
inclMwst: r.incl_mwst,
status: r.status,
receiptUrl: r.receipt_url,
receiptName: r.receipt_name,
lohnEntryId: r.lohn_entry_id,
}),
internalExpense: (r) => ({
id: r.id,
date: r.date,
category: r.category,
description: r.description,
amount: num(r.amount),
mwstRate: num(r.mwst_rate),
inclMwst: r.incl_mwst,
recurring: r.recurring,
recurringInterval: r.recurring_interval,
receiptUrl: r.receipt_url,
}),
timeEntry: (r) => ({
id: r.id,
employeeId: r.employee_id,
projectId: r.project_id,
phaseId: r.phase_id,
positionId: r.position_id,
date: r.date,
minutes: r.minutes,
startTime: r.start_time,
endTime: r.end_time,
description: r.description,
createdAt: r.created_at,
}),
employee: (r) => ({
id: r.id,
name: r.name,
personalNr: r.personal_nr,
pensum: r.pensum,
wochenstunden: num(r.wochenstunden),
ferienWochen: num(r.ferien_wochen),
pkAGSatz: num(r.pk_ag_satz),
ferienUebertragVorjahr: r.ferien_uebertrag_vorjahr || {},
_appUserId: r.app_user_id,
active: r.active,
}),
absence: (r) => ({
id: r.id,
employeeId: r.employee_id,
type: r.type_id,
date: r.date,
dateFrom: r.date_from,
dateTo: r.date_to,
startTime: r.start_time,
endTime: r.end_time,
hours: r.hours,
minutes: r.minutes,
note: r.note,
status: r.status,
createdAt: r.created_at,
}),
vacationEntry: (r) => ({
id: r.id,
employeeId: r.employee_id,
dateFrom: r.date_from,
dateTo: r.date_to,
note: r.note,
status: r.status,
originalData: r.original_data,
createdAt: r.created_at,
}),
payrollEntry: (r) => ({
id: r.id,
employeeId: r.employee_id,
year: r.year,
month: r.month,
brutto: num(r.brutto),
ahv: num(r.ahv), alv: num(r.alv), bvg: num(r.bvg),
nbu: num(r.nbu), ktg: num(r.ktg),
quellensteuer: num(r.quellensteuer),
spesen: num(r.spesen),
bonus: num(r.bonus),
netto: num(r.netto),
status: r.status,
paidAt: r.paid_at,
}),
overtimeClosing: (r) => ({
id: r.id,
employeeId: r.employee_id,
date: r.date,
saldoHours: num(r.saldo_hours),
}),
holiday: (r) => ({
date: r.date,
label: r.label,
halfDay: r.half_day,
}),
absenceType: (r) => ({ id: r.id, label: r.label, color: r.color }),
letterTemplate: (r) => ({ id: r.id, name: r.name, body: r.body }),
appRole: (r) => ({
id: r.id,
name: r.name,
permissions: r.permissions,
dashboardTemplateId: r.dashboard_template_id,
}),
dashboardTemplate: (r) => ({
id: r.id,
name: r.name,
isPublic: r.is_public,
layout: r.layout,
}),
protocol: (r) => ({
id: r.id,
number: r.number,
type: r.type,
location: r.location,
projectId: r.project_id,
projectManual: r.project_manual,
participants: r.participants || [],
traktanden: r.traktanden || [],
nextDate: r.next_date,
verteiler: r.verteiler,
createdAt: r.created_at,
}),
deliveryNote: (r, allItems = []) => ({
id: r.id,
number: r.number,
date: r.date,
clientId: r.client_id,
projectId: r.project_id,
notes: r.notes,
items: allItems
.filter(it => it.delivery_note_id === r.id)
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
.map(it => ({
id: it.id,
desc: it.description,
qty: num(it.qty),
unit: it.unit,
note: it.note,
})),
}),
blogPost: (r) => ({
id: r.id,
authorId: r.author_id,
category: r.category,
title: r.title,
body: r.body,
pinned: r.pinned,
createdAt: r.created_at,
}),
};
// ───────────────────────────────────────────────────────────────────────────
// Frontend → DB Mapping (für save())
// ───────────────────────────────────────────────────────────────────────────
export const toDB = {
studioSettings: (settings, studioId) => ({
studio_id: studioId,
setup_completed: settings.setupCompleted ?? false,
name: settings.name,
address: settings.address,
street: settings.street, zip: settings.zip, city: settings.city, country: settings.country,
email: settings.email, phone: settings.phone,
iban: settings.iban, iban_type: settings.ibanType,
mwst_nr: settings.mwst, mwst_rate: settings.mwstRate,
default_hourly_rate: settings.defaultHourlyRate,
default_wochenstunden: settings.defaultWochenstunden,
default_ferien_wochen: settings.defaultFerienWochen,
closed_months: settings.closedMonths || [],
block_mai_tag: settings.blockMaiTag,
protokoll_type_abbr: settings.protokollTypeAbbreviations || {},
logo_url: settings.logoUrl,
formats: pickKeys(settings, FORMAT_KEYS),
page_margins: pickKeys(settings, PAGE_MARGIN_KEYS),
ui: pickKeys(settings, UI_KEYS),
}),
studioRoles: (roles = [], studioId) =>
(roles || []).map((r, i) => ({
studio_id: studioId,
id: r.id,
label: r.label,
rate: r.rate,
sort: i,
})),
person: (p, studioId) => ({
id: p.id,
// Geteilte Person bleibt global (studio_id NULL); lokale Person hängt am Studio.
studio_id: p.isShared ? null : studioId,
name: p.name,
person_type: p.type,
is_auftraggeber: !!p.isAuftraggeber,
is_partner: !!p.isPartner,
street: p.street, zip: p.zip, city: p.city, country: p.country,
email: p.email || null, phone: p.phone, website: p.website,
note: p.note,
contacts: p.contacts || [],
honorar_offers: p.honorar_offers || [],
}),
project: (p, studioId) => ({
id: p.id,
studio_id: studioId,
number: p.number,
name: p.name,
client_id: p.clientId || null,
category: p.category,
billing_type: p.billingType,
hourly_rate: p.hourlyRate,
budget: p.budget,
budget_hours: p.budgetHours,
status: p.status || "aktiv",
description: p.description,
start_date: p.startDate || null,
enabled_phases: p.enabledPhases || [],
positions: p.positions || [],
custom_phases: p.customPhases || [],
project_contacts: p.projectContacts || [],
internal_members: p.internalMembers || [],
}),
projectQuoteLinks: (projects = []) =>
(projects || []).flatMap(p =>
(p.linkedQuotes || []).map(lq => ({
project_id: p.id,
quote_id: lq.quoteId,
role: lq.role || null,
}))
),
quote: (q, studioId) => ({
id: q.id,
studio_id: studioId,
number: q.number,
client_id: q.clientId || null,
project_id: q.projectId || null,
project_name: q.projectName,
date: q.date || null,
valid_until: q.validUntil || null,
mode: q.mode,
mwst: q.mwst,
notes: q.notes,
status: q.status || "entwurf",
sia_config: q.sia || null,
manual_phases: q.manualPhases || null,
free_items: q.freeItems || null,
quote_roles: q.quoteRoles || null,
}),
invoice: (inv, studioId) => ({
id: inv.id,
studio_id: studioId,
number: inv.number,
client_id: inv.clientId || null,
contact_id: inv.contactId || null,
project_id: inv.projectId || null,
quote_id: inv.quoteId || null,
date: inv.date || null,
due_date: inv.dueDate || null,
sent_date: inv.sentDate || null,
paid_date: inv.paidDate || null,
items: inv.items || [],
mwst: inv.mwst,
mwst_rate: inv.mwstRate,
notes: inv.notes,
status: inv.status || "entwurf",
invoice_kind: inv.invoiceKind,
discount_type: inv.discountType || "none",
discount_value: inv.discountValue || 0,
discount_label: inv.discountLabel,
entry_selections: inv.entrySelections || {},
qr_reference: inv.qrReference,
}),
invoiceReminders: (invoices = []) =>
(invoices || []).flatMap(inv =>
(inv.reminders || []).map(rem => ({
invoice_id: inv.id,
nr: rem.nr,
date: rem.date,
sent_date: rem.sentDate || null,
days_past: rem.daysPast,
note: rem.note,
}))
),
expense: (e, studioId) => ({
id: e.id,
studio_id: studioId,
employee_id: e.employeeId || null,
project_id: e.projectId || null,
date: e.date,
category: e.category,
description: e.description,
amount: e.amount,
mwst_rate: e.mwstRate,
incl_mwst: e.inclMwst,
status: e.status || "offen",
receipt_url: e.receiptUrl || null,
receipt_name: e.receiptName,
lohn_entry_id: e.lohnEntryId || null,
}),
internalExpense: (e, studioId) => ({
id: e.id,
studio_id: studioId,
date: e.date,
category: e.category,
description: e.description,
amount: e.amount,
mwst_rate: e.mwstRate,
incl_mwst: e.inclMwst,
recurring: !!e.recurring,
recurring_interval: e.recurringInterval || null,
receipt_url: e.receiptUrl || null,
}),
timeEntry: (t, studioId) => ({
id: t.id,
studio_id: studioId,
employee_id: t.employeeId || null,
project_id: t.projectId || null,
phase_id: t.phaseId || null,
position_id: t.positionId || null,
date: t.date,
minutes: t.minutes,
start_time: t.startTime || null,
end_time: t.endTime || null,
description: t.description,
}),
employee: (e, studioId) => ({
id: e.id,
studio_id: studioId,
name: e.name,
personal_nr: e.personalNr,
pensum: e.pensum,
wochenstunden: e.wochenstunden,
ferien_wochen: e.ferienWochen,
pk_ag_satz: e.pkAGSatz,
ferien_uebertrag_vorjahr: e.ferienUebertragVorjahr || {},
app_user_id: e._appUserId || null,
active: e.active ?? true,
}),
absence: (a, studioId) => ({
id: a.id,
studio_id: studioId,
employee_id: a.employeeId,
type_id: a.type || null,
date: a.date || null,
date_from: a.dateFrom || null,
date_to: a.dateTo || null,
start_time: a.startTime || null,
end_time: a.endTime || null,
hours: a.hours,
minutes: a.minutes,
note: a.note,
status: a.status || "pending",
}),
vacationEntry: (v, studioId) => ({
id: v.id,
studio_id: studioId,
employee_id: v.employeeId,
date_from: v.dateFrom,
date_to: v.dateTo,
note: v.note,
status: v.status || "pending",
original_data: v.originalData || null,
}),
payrollEntry: (p, studioId) => ({
id: p.id,
studio_id: studioId,
employee_id: p.employeeId,
year: p.year,
month: p.month,
brutto: p.brutto, ahv: p.ahv, alv: p.alv, bvg: p.bvg,
nbu: p.nbu, ktg: p.ktg,
quellensteuer: p.quellensteuer,
spesen: p.spesen, bonus: p.bonus, netto: p.netto,
status: p.status || "entwurf",
paid_at: p.paidAt || null,
}),
overtimeClosing: (o, studioId) => ({
id: o.id,
studio_id: studioId,
employee_id: o.employeeId,
date: o.date,
saldo_hours: o.saldoHours,
}),
holiday: (h, studioId) => ({
studio_id: studioId,
date: h.date,
label: h.label,
half_day: !!h.halfDay,
}),
absenceType: (t, studioId) => ({
studio_id: studioId,
id: t.id,
label: t.label,
color: t.color,
}),
letterTemplate: (t, studioId) => ({
studio_id: studioId,
id: t.id,
name: t.name,
body: t.body,
}),
appRole: (r, studioId) => ({
studio_id: studioId,
id: r.id,
name: r.name,
permissions: r.permissions,
dashboard_template_id: r.dashboardTemplateId || null,
}),
dashboardTemplate: (d, studioId) => ({
studio_id: studioId,
id: d.id,
name: d.name,
is_public: d.isPublic ?? true,
layout: d.layout || [],
}),
protocol: (p, studioId) => ({
id: p.id,
studio_id: studioId,
number: p.number,
type: p.type,
location: p.location,
project_id: p.projectId || null,
project_manual: p.projectManual,
participants: p.participants || [],
traktanden: p.traktanden || [],
next_date: p.nextDate || null,
verteiler: p.verteiler,
}),
deliveryNote: (d, studioId) => ({
id: d.id,
studio_id: studioId,
number: d.number,
date: d.date || null,
client_id: d.clientId || null,
project_id: d.projectId || null,
notes: d.notes,
}),
deliveryNoteItems: (deliveryNotes = []) =>
(deliveryNotes || []).flatMap(dn =>
(dn.items || []).map((it, i) => ({
id: it.id,
delivery_note_id: dn.id,
sort: i,
description: it.desc,
qty: it.qty,
unit: it.unit,
note: it.note,
}))
),
blogPost: (b, studioId) => ({
id: b.id,
studio_id: studioId,
author_id: b.authorId || null,
category: b.category,
title: b.title,
body: b.body,
pinned: !!b.pinned,
}),
};
+116
View File
@@ -0,0 +1,116 @@
import React from "react";
// Erst-Screen einer frischen Rapport-Installation: «Lokal oder Cloud?».
// Wird angezeigt, solange `localStorage["rapport_backend_chosen"]` nicht
// gesetzt ist UND noch keine lokalen Daten existieren. Sobald der User
// gewählt hat, reloaded die App und der jeweilige Wizard übernimmt:
// Lokal → bestehender Setup.jsx
// Cloud → Login mit Init-Modus oder Login-Modus (je nach Studio-Vorhandensein)
// Tauri-User geben die Server-URL immer aktiv ein. Build-time-URL ist nur
// für Web-Deploy gedacht (z.B. app.rapport.kgva.ch → 127.0.0.1:54321 lokal).
const isTauri = typeof window !== "undefined" && !!window.__TAURI_INTERNALS__;
const envCloudUrl = isTauri ? "" : (import.meta.env.VITE_SUPABASE_URL || "");
export default function BackendChoice() {
const pick = (backend, cloudUrl = "") => {
localStorage.setItem("rapport_backend_chosen", "1");
localStorage.setItem("rapport_backend", backend);
if (backend === "cloud" && cloudUrl) {
localStorage.setItem("rapport_cloud_url", cloudUrl.replace(/\/+$/, ""));
}
window.location.reload();
};
return (
<div style={{
minHeight: "100vh", minWidth: "100vw",
background: "#ebe7e1",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'DM Mono', 'Courier New', monospace",
position: "fixed", inset: 0, zIndex: 9999,
padding: 20,
}}>
<style>{`
@keyframes bc-fade-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.bc-card { animation: bc-fade-in 0.5s cubic-bezier(0.22,1,0.36,1) both; }
.bc-option {
width: 100%;
background: #fdfcfa;
border: 1.5px solid #ddd8d0;
border-radius: 14px;
padding: 22px 24px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: all 0.18s;
margin-bottom: 12px;
}
.bc-option:hover {
border-color: #9a7858;
box-shadow: 0 4px 16px rgba(154,120,88,0.12);
transform: translateY(-1px);
}
.bc-title {
font-size: 14px; font-weight: 500; color: #1a1a18; margin-bottom: 4px;
letter-spacing: 0.02em;
}
.bc-desc {
font-size: 12px; color: #888; line-height: 1.5;
}
`}</style>
<div className="bc-card" style={{
background: "transparent",
width: "100%", maxWidth: 460,
}}>
<div style={{ textAlign: "center", marginBottom: 36 }}>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 40, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>
RAPPORT
</div>
<div style={{ fontSize: 10, color: "#b0aca4", letterSpacing: "0.22em", marginTop: 10, fontWeight: 500 }}>
ERSTE EINRICHTUNG
</div>
<div style={{ width: 40, height: 1.5, background: "#ddd8d0", margin: "20px auto 0" }} />
</div>
<p style={{ fontSize: 13, color: "#666", marginBottom: 26, textAlign: "center", lineHeight: 1.6 }}>
Wie möchten Sie Rapport nutzen?
</p>
<button className="bc-option" onClick={() => pick("local")}>
<div className="bc-title">Lokal auf diesem Gerät</div>
<div className="bc-desc">
Daten liegen ausschliesslich in diesem Browser / dieser App. Kein Server nötig.
Ideal zum Ausprobieren oder als Solo-Setup.
</div>
</button>
{envCloudUrl ? (
<button className="bc-option" onClick={() => pick("cloud", envCloudUrl)}>
<div className="bc-title">Mit Cloud-Server verbinden</div>
<div className="bc-desc">
Daten liegen auf dem konfigurierten Server. Mehrere Geräte / Mitarbeiter
können gemeinsam arbeiten. Vorkonfiguriert: <code style={{ fontSize: 11, color: "#9a7858" }}>{(() => { try { return new URL(envCloudUrl).host; } catch { return envCloudUrl; } })()}</code>
</div>
</button>
) : (
<button className="bc-option" onClick={() => pick("cloud")}>
<div className="bc-title">Mit Cloud-Server verbinden</div>
<div className="bc-desc">
Daten liegen auf einem Supabase-Server (z.B. Mac Mini im Büro, Docker im LAN).
Die Server-Adresse geben Sie im nächsten Schritt ein.
</div>
</button>
)}
<div style={{ marginTop: 24, fontSize: 11, color: "#aaa", textAlign: "center", lineHeight: 1.6 }}>
Sie können später in den Einstellungen wechseln.
</div>
</div>
</div>
);
}
-452
View File
@@ -1,452 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm } from "../components/UI.jsx";
export default
function Clients({ data, update, modal, setModal, setView }) {
const clients = data.clients || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToClient || null;
window.__navToClient = null;
return id;
});
const [search, setSearch] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const [contactModal, setContactModal] = useState(null);
const [contactForm, setContactForm] = useState({ name: "", position: "", email: "", phone: "" });
const [showHauptPicker, setShowHauptPicker] = useState(false);
const emptyForm = {
name: "", street: "", zip: "", city: "", country: "CH",
email: "", phone: "", website: "",
contacts: [],
_contactName: "", _contactPosition: "",
};
const [form, setForm] = useState(emptyForm);
const selectedClient = clients.find(c => c.id === selectedId) || null;
// ── Client speichern ──
const save = () => {
if (!form.name.trim()) return;
const { _contactName, _contactPosition, ...clientData } = form;
let contacts = clientData.contacts || [];
if (_contactName.trim() && !modal?.id) {
contacts = [{ id: generateId(), name: _contactName.trim(), position: _contactPosition.trim(), email: "", phone: "" }];
}
const client = { ...clientData, contacts, id: modal?.id || generateId() };
update("clients", modal?.id ? clients.map(c => c.id === modal.id ? client : c) : [...clients, client]);
setModal(null);
};
const openNew = () => { setForm(emptyForm); setModal({ type: "client" }); };
const openEdit = (c) => {
setForm({ ...emptyForm, ...c, _contactName: "", _contactPosition: "" });
setModal({ type: "client", id: c.id });
};
const del = async (id) => {
if (await askConfirm("Kunde löschen? Alle zugehörigen Projekte verlieren die Kundenzuordnung.")) {
update("clients", clients.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Kontakt speichern ──
const saveContact = () => {
if (!contactForm.name.trim()) return;
const client = clients.find(c => c.id === contactModal.clientId);
if (!client) return;
const contacts = client.contacts || [];
const updated = contactModal.contactId
? contacts.map(ct => ct.id === contactModal.contactId ? { ...ct, ...contactForm } : ct)
: [...contacts, { ...contactForm, id: generateId() }];
update("clients", clients.map(c => c.id === client.id ? { ...c, contacts: updated } : c));
setContactModal(null);
};
const delContact = async (clientId, contactId) => {
if (await askConfirm("Kontaktperson löschen?")) {
const client = clients.find(c => c.id === clientId);
update("clients", clients.map(c => c.id === clientId ? { ...c, contacts: (c.contacts || []).filter(ct => ct.id !== contactId) } : c));
}
};
// ── Detail-Ansicht ──
if (selectedId && selectedClient) {
const projs = (data.projects || []).filter(p => p.clientId === selectedId).sort((a, b) => (b.startDate || "").localeCompare(a.startDate || ""));
const invoices = (data.invoices || []).filter(i => i.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const quotes = (data.quotes || []).filter(q => q.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const contacts = selectedClient.contacts || [];
const hauptkontakt = contacts[0] || null;
const addressLine = [selectedClient.street, [selectedClient.zip, selectedClient.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
const navTo = (view) => { window.__navClientId = selectedId; setView(view); };
const formatCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kunden</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedClient.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedClient)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedClient.email, href: `mailto:${selectedClient.email}` },
{ label: "Telefon", value: selectedClient.phone },
{ label: "Website", value: selectedClient.website, href: selectedClient.website?.startsWith("http") ? selectedClient.website : selectedClient.website ? `https://${selectedClient.website}` : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{contacts.length > 0 && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2", position: "relative" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, color: "#888" }}>HAUPTKONTAKT</div>
{contacts.length > 1 && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: "2px 8px" }} onClick={() => setShowHauptPicker(v => !v)}>
ändern
</button>
)}
</div>
{showHauptPicker ? (
<div style={{ border: "1px solid #ece8e2", borderRadius: 6, overflow: "hidden" }}>
{contacts.map((ct, i) => (
<button key={ct.id} onClick={() => {
const reordered = [ct, ...contacts.filter(x => x.id !== ct.id)];
update("clients", clients.map(c => c.id === selectedId ? { ...c, contacts: reordered } : c));
setShowHauptPicker(false);
}} style={{
display: "block", width: "100%", textAlign: "left", padding: "9px 12px",
background: i === 0 ? "#f5f2ec" : "white", border: "none", borderBottom: i < contacts.length - 1 ? "1px solid #f0ede8" : "none",
cursor: "pointer", fontFamily: "inherit",
}}>
<div style={{ fontWeight: i === 0 ? 600 : 400, fontSize: 13 }}>{ct.name}</div>
{ct.position && <div style={{ fontSize: 11, color: "#888" }}>{ct.position}</div>}
</button>
))}
</div>
) : hauptkontakt ? (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptkontakt.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 6 }}>
{hauptkontakt.email && <a href={`mailto:${hauptkontakt.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptkontakt.email}</a>}
{hauptkontakt.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptkontakt.phone}</span>}
</div>
</>
) : null}
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: contacts.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({contacts.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setContactForm({ name: "", position: "", email: "", phone: "" }); setContactModal({ clientId: selectedId }); }}>+ Hinzufügen</button>
</div>
{contacts.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
) : (
contacts.map((ct, i) => (
<div key={ct.id} style={{ padding: "12px 20px", borderBottom: i < contacts.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{ct.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{ct.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{ct.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{ct.email && <a href={`mailto:${ct.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{ct.email}</a>}
{ct.phone && <span style={{ fontSize: 12, color: "#555" }}>{ct.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setContactForm({ name: ct.name, position: ct.position || "", email: ct.email || "", phone: ct.phone || "" }); setContactModal({ clientId: selectedId, contactId: ct.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delContact(selectedId, ct.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Projekte */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: projs.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>PROJEKTE ({projs.length})</span>
{projs.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("projects")}>Alle anzeigen </button>}
</div>
{projs.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Projekte.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Projekt</th><th>Kategorie</th><th>Status</th><th style={{ textAlign: "right" }}>Budget</th></tr></thead>
<tbody>
{projs.slice(0, 5).map(p => (
<tr key={p.id}>
<td><strong>{p.name}</strong>{p.number && <span style={{ fontSize: 11, color: "#aaa", marginLeft: 6 }}>{p.number}</span>}</td>
<td style={{ fontSize: 12, color: "#888" }}>{p.category || "—"}</td>
<td><span style={{ fontSize: 11, color: p.status === "aktiv" ? "#2d6a4f" : "#888" }}>{p.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12 }}>{p.budget > 0 ? formatCHF(p.budget) : "—"}</td>
</tr>
))}
</tbody>
</table>
{projs.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{projs.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("projects")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Rechnungen */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: invoices.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>RECHNUNGEN ({invoices.length})</span>
{invoices.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("invoices")}>Alle anzeigen </button>}
</div>
{invoices.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Rechnungen.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Projekt</th><th>Status</th><th style={{ textAlign: "right" }}>Betrag</th></tr></thead>
<tbody>
{invoices.slice(0, 5).map(inv => {
const proj = inv.projectId ? (data.projects || []).find(p => p.id === inv.projectId) : null;
return (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(inv.date)}</td>
<td style={{ fontSize: 12, color: "#555" }}>{proj?.name || "—"}</td>
<td><span style={{ fontSize: 11, color: inv.status === "bezahlt" ? "#2d6a4f" : inv.status === "überfällig" ? "#8a1a1a" : "#888" }}>{inv.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(inv.total)}</td>
</tr>
);
})}
</tbody>
</table>
{invoices.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{invoices.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("invoices")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Offerten */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: quotes.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>OFFERTEN ({quotes.length})</span>
{quotes.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("quotes")}>Alle anzeigen </button>}
</div>
{quotes.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Offerten.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Modus</th><th>Status</th><th style={{ textAlign: "right" }}>Honorar</th></tr></thead>
<tbody>
{quotes.slice(0, 5).map(q => (
<tr key={q.id}>
<td><strong>{q.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(q.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Frei"}</td>
<td><span style={{ fontSize: 11, color: q.status === "genehmigt" ? "#2d6a4f" : "#888" }}>{q.status || "—"}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(q.total)}</td>
</tr>
))}
</tbody>
</table>
{quotes.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{quotes.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("quotes")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Kontakt-Modal */}
{contactModal && (
<Modal title={contactModal.contactId ? "Kontakt bearbeiten" : "Neuer Ansprechpartner"} onClose={() => setContactModal(null)} onSave={saveContact}>
<div className="form-row">
<FormField label="Name *"><input value={contactForm.name} onChange={e => setContactForm({ ...contactForm, name: e.target.value })} autoFocus /></FormField>
<FormField label="Funktion / Position"><input value={contactForm.position} onChange={e => setContactForm({ ...contactForm, position: e.target.value })} placeholder="z.B. Geschäftsführer, Bauleiter…" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={contactForm.email} onChange={e => setContactForm({ ...contactForm, email: e.target.value })} /></FormField>
<FormField label="Telefon"><input value={contactForm.phone} onChange={e => setContactForm({ ...contactForm, phone: e.target.value })} /></FormField>
</div>
</Modal>
)}
{/* Client-Edit-Modal */}
{modal?.type === "client" && modal.id && (
<Modal title="Kunde bearbeiten" onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm)}
</Modal>
)}
</div>
);
}
// ── Listen-Ansicht ──
const filteredClients = clients.filter(c => {
if (!search) return true;
const q = search.toLowerCase();
return [c.name, c.city, c.email, c.street, ...(c.contacts || []).map(ct => ct.name)].some(v => v?.toLowerCase().includes(q));
});
const clientGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filteredClients }];
if (groupBy === "alpha") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "city") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.city || "Ohne Ort"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ClientTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firmenname</th>
<th>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ textAlign: "center", width: 80 }}>Kontakte</th>
<th style={{ textAlign: "center", width: 80 }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const projs = (data.projects || []).filter(p => p.clientId === c.id).length;
const cts = c.contacts || [];
const hauptkontakt = cts[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{hauptkontakt ? (
<>
<div style={{ fontWeight: 500 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888" }}>{hauptkontakt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{cts.length || "—"}</td>
<td style={{ textAlign: "center", color: projs ? "#2d6a4f" : "#ccc", fontSize: 12, fontWeight: projs ? 600 : 400 }}>{projs || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kunden" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kunde</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, alignItems: "center" }}>
<input placeholder="Suchen…" value={search} onChange={e => setSearch(e.target.value)}
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 170 }}>
<option value="alpha">Alphabetisch</option>
<option value="city">Nach Ort</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{clients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kunden erfasst.</div>
) : filteredClients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : clientGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ClientTable items={group.items} />
</div>
))}
{modal?.type === "client" && (
<Modal title={modal.id ? "Kunde bearbeiten" : "Neuer Kunde"} onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm, !modal.id)}
</Modal>
)}
</div>
);
}
function clientFormFields(form, setForm, isNew = false) {
return (
<>
<FormField label="Firmenname *">
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus placeholder="z.B. Müller Immobilien AG" />
</FormField>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={form.street || ""} onChange={e => setForm({ ...form, street: e.target.value })} placeholder="Bahnhofstrasse 1" /></FormField>
<FormField label="PLZ"><input value={form.zip || ""} onChange={e => setForm({ ...form, zip: e.target.value })} style={{ maxWidth: 100 }} /></FormField>
<FormField label="Ort"><input value={form.city || ""} onChange={e => setForm({ ...form, city: e.target.value })} /></FormField>
<FormField label="Land"><input value={form.country || "CH"} onChange={e => setForm({ ...form, country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 70 }} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={form.email || ""} onChange={e => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Telefon Firma"><input value={form.phone || ""} onChange={e => setForm({ ...form, phone: e.target.value })} /></FormField>
<FormField label="Website"><input value={form.website || ""} onChange={e => setForm({ ...form, website: e.target.value })} placeholder="www.beispiel.ch" /></FormField>
</div>
{isNew && (
<>
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Referenzperson">
<input value={form._contactName || ""} onChange={e => setForm({ ...form, _contactName: e.target.value })} placeholder="z.B. Hans Müller" />
</FormField>
<FormField label="Funktion / Position">
<input value={form._contactPosition || ""} onChange={e => setForm({ ...form, _contactPosition: e.target.value })} placeholder="z.B. Geschäftsführer" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Ansprechpartner können in der Kundendetailseite hinzugefügt werden.</div>
</>
)}
</>
);
}
+238
View File
@@ -0,0 +1,238 @@
import React, { useState } from "react";
// Cloud-Erst-Einrichtung — der Wizard, der erscheint, wenn der Browser auf
// eine leere Cloud-Instanz trifft (0 Studios). Designs / Schritt-Struktur ist
// bewusst parallel zum lokalen Setup.jsx, nur die Endpunkte sind anders:
// Schritt 1: Studio-Stammdaten
// Schritt 2: Admin-Account (Email + Passwort + Anzeigename)
// Schritt 3: Buchhaltung (optional) + Übersicht + Abschluss
//
// Bei Submit wird der `cloudInit`-Prop aus App.jsx aufgerufen — der orchestriert
// signUp + ensureProfile + createStudio + load + handleLogin.
const C = {
bg: "#ebe7e1", surface: "#fdfcfa", surface2: "#f7f4f0",
border: "#ddd8d0", border2: "#e6e1da",
text: "#1a1a18", text3: "#6a6660", text4: "#8c8880",
danger: "#8a1a1a", dangerBg: "#fdf2f2", dangerBorder: "#e0b0b0",
};
const S = {
wrap: { background: C.bg, minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'DM Mono','Courier New',monospace", color: C.text, padding: "32px 16px" },
card: { width: "100%", maxWidth: 460, background: C.surface, borderRadius: 14, padding: "44px 40px", boxShadow: "0 2px 24px rgba(60,50,40,0.10)", border: `1px solid ${C.border}`, maxHeight: "92vh", overflowY: "auto" },
logo: { fontFamily: "Krungthep,'Archivo Black',sans-serif", fontSize: 34, color: C.text, letterSpacing: "-0.02em", textAlign: "center", marginBottom: 4 },
sub: { textAlign: "center", fontSize: 10, color: C.text4, letterSpacing: "0.14em", marginBottom: 32 },
progress: { display: "flex", gap: 6, justifyContent: "center", marginBottom: 32 },
dot: (active, done) => ({ width: active ? 22 : 8, height: 8, borderRadius: 4, background: done ? C.text : active ? C.text : C.border, opacity: done ? 1 : active ? 1 : 0.4, transition: "all 0.3s" }),
label: { fontSize: 9, letterSpacing: "0.14em", color: C.text4, display: "block", marginBottom: 5, marginTop: 16 },
input: { width: "100%", boxSizing: "border-box", background: C.surface, border: `1px solid ${C.border}`, borderRadius: 6, padding: "9px 12px", color: C.text, fontFamily: "'DM Mono',monospace", fontSize: 13, outline: "none", transition: "border-color 0.15s" },
inputErr: { border: `1px solid ${C.dangerBorder}` },
textarea: { width: "100%", boxSizing: "border-box", background: C.surface, border: `1px solid ${C.border}`, borderRadius: 6, padding: "9px 12px", color: C.text, fontFamily: "'DM Mono',monospace", fontSize: 13, outline: "none", resize: "vertical", minHeight: 64 },
err: { fontSize: 11, color: C.danger, marginTop: 4 },
hint: { fontSize: 11, color: C.text4, marginTop: 5, lineHeight: 1.5 },
btnPrimary: { width: "100%", background: C.text, border: "none", borderRadius: 8, padding: "12px 0", color: "#fff", fontFamily: "'DM Mono',monospace", fontSize: 13, cursor: "pointer", marginTop: 28, letterSpacing: "0.04em", transition: "opacity 0.15s" },
btnGhost: { width: "100%", background: "transparent", border: `1px solid ${C.border}`, borderRadius: 8, padding: "10px 0", color: C.text4, fontFamily: "'DM Mono',monospace", fontSize: 12, cursor: "pointer", marginTop: 10 },
};
export default function CloudSetup({ cloudInit, cloudUrl }) {
const TOTAL = 3;
const [step, setStep] = useState(1);
const [studio, setStudio] = useState({ name: "", street: "", zip: "", city: "", email: "", phone: "", iban: "", mwst: "", hourlyRate: "" });
const [account, setAccount] = useState({ displayName: "", email: "", password: "", confirm: "" });
const [errors, setErrors] = useState({});
const [showPw, setShowPw] = useState(false);
const [busy, setBusy] = useState(false);
const [submitErr, setSubmitErr] = useState("");
const setS = (k, v) => setStudio(s => ({ ...s, [k]: v }));
const setA = (k, v) => setAccount(a => ({ ...a, [k]: v }));
const clearErr = k => setErrors(e => { const n = { ...e }; delete n[k]; return n; });
const validate = () => {
const errs = {};
if (step === 1) {
if (!studio.name.trim()) errs.name = "Pflichtfeld";
}
if (step === 2) {
if (!account.displayName.trim()) errs.displayName = "Pflichtfeld";
if (!account.email.trim() || !/.+@.+\..+/.test(account.email)) errs.email = "Gültige Email";
if (account.password.length < 6) errs.password = "Mindestens 6 Zeichen";
if (account.password !== account.confirm) errs.confirm = "Passwörter stimmen nicht überein";
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const next = () => { if (validate()) setStep(s => s + 1); };
const back = () => setStep(s => s - 1);
const finish = async () => {
if (busy) return;
setBusy(true);
setSubmitErr("");
const extras = {};
if (studio.street.trim()) extras.street = studio.street.trim();
if (studio.zip.trim()) extras.zip = studio.zip.trim();
if (studio.city.trim()) extras.city = studio.city.trim();
if (studio.email.trim()) extras.email = studio.email.trim();
if (studio.phone.trim()) extras.phone = studio.phone.trim();
if (studio.iban.trim()) extras.iban = studio.iban.trim().replace(/\s+/g, "");
if (studio.mwst.trim()) extras.mwst = studio.mwst.trim();
if (studio.hourlyRate.trim()) {
const n = Number(studio.hourlyRate);
if (!Number.isNaN(n) && n > 0) extras.defaultHourlyRate = n;
}
const res = await cloudInit(account.email.trim(), account.password, account.displayName.trim(), studio.name.trim(), extras);
if (!res?.ok) {
setSubmitErr(res?.error || "Einrichtung fehlgeschlagen.");
setBusy(false);
}
// bei Erfolg übernimmt App.jsx (currentUser setzen) — keine weitere Aktion nötig
};
const progressEl = (
<div style={S.progress}>
{Array.from({ length: TOTAL }, (_, i) => (
<div key={i} style={S.dot(i + 1 === step, i + 1 < step)} />
))}
</div>
);
const renderHeader = (n, title, lead) => (
<>
<div style={S.logo}>RAPPORT</div>
<div style={S.sub}>SCHRITT {n} VON {TOTAL} · CLOUD</div>
{progressEl}
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, color: C.text, marginBottom: 6 }}>{title}</div>
{lead && <div style={{ fontSize: 12, color: C.text3, lineHeight: 1.65, marginBottom: 20 }}>{lead}</div>}
</>
);
// ── Step 1: Studio ─────────────────────────────────────────────────────────
if (step === 1) return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(1, "Willkommen.", "Lass uns dein Studio einrichten. Adresse und Buchhaltung sind optional und können später ergänzt werden.")}
{cloudUrl && (
<div style={{ fontSize: 10, color: C.text4, marginBottom: 6, letterSpacing: "0.04em" }}>
Cloud-Server: <code style={{ color: C.text3 }}>{(() => { try { return new URL(cloudUrl).host; } catch { return cloudUrl; } })()}</code>
</div>
)}
<label style={S.label}>STUDIO / UNTERNEHMEN *</label>
<input style={{ ...S.input, ...(errors.name ? S.inputErr : {}) }} placeholder="Muster Architektur GmbH" autoFocus
value={studio.name} onChange={e => { setS("name", e.target.value); clearErr("name"); }}
onKeyDown={e => e.key === "Enter" && next()} />
{errors.name && <div style={S.err}>{errors.name}</div>}
<label style={S.label}>STRASSE</label>
<input style={S.input} placeholder="Musterstrasse 1" value={studio.street} onChange={e => setS("street", e.target.value)} />
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", gap: 12 }}>
<div>
<label style={S.label}>PLZ</label>
<input style={S.input} placeholder="8001" value={studio.zip} onChange={e => setS("zip", e.target.value)} />
</div>
<div>
<label style={S.label}>ORT</label>
<input style={S.input} placeholder="Zürich" value={studio.city} onChange={e => setS("city", e.target.value)} />
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={S.label}>E-MAIL</label>
<input style={S.input} type="email" placeholder="mail@studio.ch" value={studio.email} onChange={e => setS("email", e.target.value)} />
</div>
<div>
<label style={S.label}>TELEFON</label>
<input style={S.input} placeholder="+41 44 000 00 00" value={studio.phone} onChange={e => setS("phone", e.target.value)} />
</div>
</div>
<button style={S.btnPrimary} onClick={next}>Weiter </button>
</div>
</div>
);
// ── Step 2: Account ────────────────────────────────────────────────────────
if (step === 2) return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(2, "Dein Account.", "Mit dieser Email loggst du dich künftig ein. Du wirst Admin des Studios — weitere Mitarbeitende können später eingeladen werden.")}
<label style={S.label}>DEIN NAME *</label>
<input style={{ ...S.input, ...(errors.displayName ? S.inputErr : {}) }} placeholder="Karim Varano" autoFocus
value={account.displayName} onChange={e => { setA("displayName", e.target.value); clearErr("displayName"); }} />
{errors.displayName && <div style={S.err}>{errors.displayName}</div>}
<label style={S.label}>EMAIL *</label>
<input style={{ ...S.input, ...(errors.email ? S.inputErr : {}) }} type="email" placeholder="karim@studio.ch"
value={account.email} onChange={e => { setA("email", e.target.value); clearErr("email"); }} />
{errors.email && <div style={S.err}>{errors.email}</div>}
<label style={S.label}>PASSWORT *</label>
<div style={{ position: "relative" }}>
<input style={{ ...S.input, ...(errors.password ? S.inputErr : {}), paddingRight: 80 }} type={showPw ? "text" : "password"} placeholder="Mindestens 6 Zeichen"
value={account.password} onChange={e => { setA("password", e.target.value); clearErr("password"); }} />
<button onClick={() => setShowPw(v => !v)}
style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", color: C.text4, cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}>
{showPw ? "verbergen" : "anzeigen"}
</button>
</div>
{errors.password && <div style={S.err}>{errors.password}</div>}
<label style={S.label}>PASSWORT BESTÄTIGEN *</label>
<input style={{ ...S.input, ...(errors.confirm ? S.inputErr : {}) }} type={showPw ? "text" : "password"} placeholder="Nochmals eingeben"
value={account.confirm} onChange={e => { setA("confirm", e.target.value); clearErr("confirm"); }}
onKeyDown={e => e.key === "Enter" && next()} />
{errors.confirm && <div style={S.err}>{errors.confirm}</div>}
<button style={S.btnPrimary} onClick={next}>Weiter </button>
<button style={S.btnGhost} onClick={back}> Zurück</button>
</div>
</div>
);
// ── Step 3: Buchhaltung + Übersicht + Abschluss ───────────────────────────
return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(3, "Buchhaltung & Abschluss.", "Alle Felder sind optional. Du kannst sie auch später in den Einstellungen ergänzen.")}
<label style={S.label}>IBAN</label>
<input style={S.input} placeholder="CH00 0000 0000 0000 0000 0" value={studio.iban} onChange={e => setS("iban", e.target.value)} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 110px", gap: 12 }}>
<div>
<label style={S.label}>MWST-NR</label>
<input style={S.input} placeholder="CHE-000.000.000 MWST" value={studio.mwst} onChange={e => setS("mwst", e.target.value)} />
</div>
<div>
<label style={S.label}>STD-ANSATZ</label>
<input style={S.input} type="number" placeholder="120" min="0" step="5" value={studio.hourlyRate} onChange={e => setS("hourlyRate", e.target.value)} />
</div>
</div>
<div style={{ marginTop: 24, padding: "14px 16px", background: C.surface2, borderRadius: 8, border: `1px solid ${C.border}` }}>
<div style={{ fontSize: 10, color: C.text4, letterSpacing: "0.12em", marginBottom: 10 }}>ÜBERSICHT</div>
{[
{ label: "Studio", value: studio.name },
{ label: "Account", value: `${account.displayName} · ${account.email}` },
{ label: "Adresse", value: [studio.street, [studio.zip, studio.city].filter(Boolean).join(" ")].filter(Boolean).join(", ") || "—" },
].map(({ label, value }) => (
<div key={label} style={{ padding: "6px 0", borderBottom: `1px solid ${C.border2}`, display: "flex", justifyContent: "space-between", gap: 16, alignItems: "baseline" }}>
<div style={{ fontSize: 10, color: C.text4, letterSpacing: "0.1em", flexShrink: 0 }}>{label.toUpperCase()}</div>
<div style={{ fontSize: 12, color: C.text, textAlign: "right" }}>{value}</div>
</div>
))}
</div>
{submitErr && <div style={{ marginTop: 14, padding: "10px 14px", background: C.dangerBg, border: `1px solid ${C.dangerBorder}`, borderRadius: 8, fontSize: 11, color: C.danger }}>{submitErr}</div>}
<button style={{ ...S.btnPrimary, opacity: busy ? 0.6 : 1 }} onClick={finish} disabled={busy}>{busy ? "Wird eingerichtet …" : "Rapport starten →"}</button>
<button style={S.btnGhost} onClick={back} disabled={busy}> Zurück</button>
</div>
</div>
);
}
-456
View File
@@ -1,456 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm , DateInput } from "../components/UI.jsx";
const CONTACT_TYPES = [
"Elektroplaner", "HLKSE-Planer", "Statiker", "Tragwerksplaner",
"Kostenplaner", "Landschaftsarchitekt", "Bauphysiker",
"Vermessungsingenieur", "Brandschutzspezialist", "Geologe",
"Generalunternehmer", "Fachplaner", "Sonstiges",
];
const fmtCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
export default
function Contacts({ data, update }) {
const contacts = data.contacts || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToContact || null;
window.__navToContact = null;
return id;
});
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const emptyFirm = {
name: "", type: "", street: "", zip: "", city: "", email: "", phone: "", website: "", note: "",
contacts: [], honorarOffers: [],
_personName: "", _personPosition: "",
};
const [firmModal, setFirmModal] = useState(null);
const [firmForm, setFirmForm] = useState(emptyFirm);
const [personModal, setPersonModal] = useState(null);
const [personForm, setPersonForm] = useState({ name: "", position: "", email: "", phone: "" });
const [honorarModal, setHonorarModal] = useState(null);
const [honorarForm, setHonorarForm] = useState({ date: "", amount: "", phase: "", description: "", note: "" });
const selectedContact = contacts.find(c => c.id === selectedId) || null;
// ── Firm CRUD ──
const saveFirm = () => {
if (!firmForm.name.trim()) return;
const { _personName, _personPosition, ...firmData } = firmForm;
let persons = firmData.contacts || [];
if (_personName.trim() && !firmModal?.id) {
persons = [{ id: generateId(), name: _personName.trim(), position: _personPosition.trim(), email: "", phone: "" }];
}
const firm = { ...firmData, contacts: persons, id: firmModal?.id || generateId() };
update("contacts", firmModal?.id ? contacts.map(c => c.id === firmModal.id ? firm : c) : [...contacts, firm]);
setFirmModal(null);
};
const openNew = () => { setFirmForm(emptyFirm); setFirmModal({}); };
const openEdit = (c) => { setFirmForm({ ...emptyFirm, ...c, _personName: "", _personPosition: "" }); setFirmModal({ id: c.id }); };
const delFirm = async (id) => {
if (await askConfirm("Kontakt löschen?")) {
update("contacts", contacts.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Person CRUD ──
const savePerson = () => {
if (!personForm.name.trim()) return;
const firm = contacts.find(c => c.id === personModal.contactId);
if (!firm) return;
const persons = firm.contacts || [];
const updated = personModal.personId
? persons.map(p => p.id === personModal.personId ? { ...p, ...personForm } : p)
: [...persons, { ...personForm, id: generateId() }];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, contacts: updated } : c));
setPersonModal(null);
};
const delPerson = async (contactId, personId) => {
if (await askConfirm("Person löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, contacts: (c.contacts || []).filter(p => p.id !== personId) } : c));
}
};
// ── Honorar CRUD ──
const saveHonorar = () => {
const firm = contacts.find(c => c.id === honorarModal.contactId);
if (!firm) return;
const offers = firm.honorarOffers || [];
const offer = { id: honorarModal.offerId || generateId(), date: honorarForm.date, amount: parseFloat(honorarForm.amount) || 0, phase: honorarForm.phase, description: honorarForm.description, note: honorarForm.note };
const updated = honorarModal.offerId ? offers.map(o => o.id === honorarModal.offerId ? offer : o) : [...offers, offer];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, honorarOffers: updated } : c));
setHonorarModal(null);
};
const delHonorar = async (contactId, offerId) => {
if (await askConfirm("Honorarangebot löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, honorarOffers: (c.honorarOffers || []).filter(o => o.id !== offerId) } : c));
}
};
// ── Form fields (shared new/edit) ──
const firmFormFields = (isNew) => (
<>
<div className="form-row">
<FormField label="Firmenname *">
<input value={firmForm.name} onChange={e => setFirmForm(f => ({ ...f, name: e.target.value }))} autoFocus placeholder="z.B. Elektroplaner AG" />
</FormField>
<FormField label="Typ">
<select value={firmForm.type} onChange={e => setFirmForm(f => ({ ...f, type: e.target.value }))}>
<option value=""> wählen </option>
{CONTACT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={firmForm.street || ""} onChange={e => setFirmForm(f => ({ ...f, street: e.target.value }))} /></FormField>
<FormField label="PLZ"><input value={firmForm.zip || ""} onChange={e => setFirmForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 90 }} /></FormField>
<FormField label="Ort"><input value={firmForm.city || ""} onChange={e => setFirmForm(f => ({ ...f, city: e.target.value }))} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={firmForm.email || ""} onChange={e => setFirmForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon Firma"><input value={firmForm.phone || ""} onChange={e => setFirmForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
<FormField label="Website"><input value={firmForm.website || ""} onChange={e => setFirmForm(f => ({ ...f, website: e.target.value }))} placeholder="www.beispiel.ch" /></FormField>
</div>
<FormField label="Bemerkung"><input value={firmForm.note || ""} onChange={e => setFirmForm(f => ({ ...f, note: e.target.value }))} /></FormField>
{isNew && (
<>
<div className="section-divider" style={{ marginTop: 16, marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Ansprechpartner">
<input value={firmForm._personName || ""} onChange={e => setFirmForm(f => ({ ...f, _personName: e.target.value }))} placeholder="z.B. Max Muster" />
</FormField>
<FormField label="Funktion / Position">
<input value={firmForm._personPosition || ""} onChange={e => setFirmForm(f => ({ ...f, _personPosition: e.target.value }))} placeholder="z.B. Projektleiter" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Personen können in der Detailansicht hinzugefügt werden.</div>
</>
)}
</>
);
// ── Detail view ──
if (selectedId && selectedContact) {
const persons = selectedContact.contacts || [];
const offers = selectedContact.honorarOffers || [];
const hauptperson = persons[0] || null;
const linkedProjects = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === selectedId));
const addressLine = [selectedContact.street, [selectedContact.zip, selectedContact.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kontakte</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
{selectedContact.type && <div style={{ fontSize: 11, color: "#888", marginBottom: 4, letterSpacing: "0.08em" }}>{selectedContact.type.toUpperCase()}</div>}
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedContact.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedContact)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => delFirm(selectedContact.id)}>Löschen</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedContact.email, href: selectedContact.email ? `mailto:${selectedContact.email}` : null },
{ label: "Telefon", value: selectedContact.phone },
{ label: "Website", value: selectedContact.website, href: selectedContact.website ? (selectedContact.website.startsWith("http") ? selectedContact.website : `https://${selectedContact.website}`) : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{selectedContact.note && <div style={{ marginTop: 12, fontSize: 12, color: "#555", lineHeight: 1.5 }}>{selectedContact.note}</div>}
{persons.length > 0 && hauptperson && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>HAUPTKONTAKT</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptperson.name}</div>
{hauptperson.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptperson.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{hauptperson.email && <a href={`mailto:${hauptperson.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptperson.email}</a>}
{hauptperson.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptperson.phone}</span>}
</div>
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: persons.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({persons.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setPersonForm({ name: "", position: "", email: "", phone: "" }); setPersonModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{persons.length === 0
? <div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
: persons.map((p, i) => (
<div key={p.id} style={{ padding: "12px 20px", borderBottom: i < persons.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{p.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{p.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{p.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{p.email && <a href={`mailto:${p.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{p.email}</a>}
{p.phone && <span style={{ fontSize: 12, color: "#555" }}>{p.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setPersonForm({ name: p.name, position: p.position || "", email: p.email || "", phone: p.phone || "" }); setPersonModal({ contactId: selectedId, personId: p.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delPerson(selectedId, p.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
}
</div>
</div>
{/* Honorar-Angebote */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: offers.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>HONORAR-ANGEBOTE ({offers.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setHonorarForm({ date: new Date().toISOString().slice(0, 10), amount: "", phase: "", description: "", note: "" }); setHonorarModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{offers.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Honorar-Angebote erfasst.</div>
: (
<table>
<thead><tr><th style={{ width: 110 }}>Datum</th><th>Beschrieb</th><th style={{ width: 120 }}>Phase</th><th style={{ width: 140, textAlign: "right" }}>Betrag</th><th style={{ width: 70 }}></th></tr></thead>
<tbody>
{[...offers].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(o => (
<tr key={o.id}>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(o.date)}</td>
<td>
<div style={{ fontSize: 13 }}>{o.description || <span style={{ color: "#aaa" }}></span>}</div>
{o.note && <div style={{ fontSize: 11, color: "#888" }}>{o.note}</div>}
</td>
<td style={{ fontSize: 12, color: "#888" }}>{o.phase || "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtCHF(o.amount)}</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setHonorarForm({ date: o.date || "", amount: o.amount?.toString() || "", phase: o.phase || "", description: o.description || "", note: o.note || "" }); setHonorarModal({ contactId: selectedId, offerId: o.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delHonorar(selectedId, o.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
{offers.length > 1 && (
<tfoot>
<tr>
<td colSpan={3} style={{ textAlign: "right", fontSize: 11, color: "#888", paddingRight: 8 }}>Total</td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{fmtCHF(offers.reduce((s, o) => s + (parseFloat(o.amount) || 0), 0))}</td>
<td />
</tr>
</tfoot>
)}
</table>
)
}
</div>
{/* Beteiligt an */}
{linkedProjects.length > 0 && (
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>BETEILIGT AN ({linkedProjects.length})</div>
<table>
<thead><tr><th>Projekt</th><th style={{ width: 160 }}>Kunde</th><th style={{ width: 110 }}>Status</th></tr></thead>
<tbody>
{linkedProjects.map(proj => {
const client = (data.clients || []).find(c => c.id === proj.clientId);
return (
<tr key={proj.id}>
<td><strong>{proj.number ? <span style={{ color: "#b07848", marginRight: 8 }}>{proj.number}</span> : null}{proj.name}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{client?.name || "—"}</td>
<td><span style={{ fontSize: 11, padding: "2px 8px", borderRadius: 3, background: "#f5f2ec", color: "#555" }}>{proj.status}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Person modal */}
{personModal && (
<Modal title={personModal.personId ? "Person bearbeiten" : "Person hinzufügen"} onClose={() => setPersonModal(null)} onSave={savePerson}>
<div className="form-row">
<FormField label="Name *"><input value={personForm.name} onChange={e => setPersonForm(f => ({ ...f, name: e.target.value }))} autoFocus /></FormField>
<FormField label="Funktion / Rolle"><input value={personForm.position} onChange={e => setPersonForm(f => ({ ...f, position: e.target.value }))} placeholder="z.B. Projektleiter" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={personForm.email} onChange={e => setPersonForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon"><input value={personForm.phone} onChange={e => setPersonForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
</div>
</Modal>
)}
{/* Honorar modal */}
{honorarModal && (
<Modal title={honorarModal.offerId ? "Angebot bearbeiten" : "Honorar-Angebot erfassen"} onClose={() => setHonorarModal(null)} onSave={saveHonorar}>
<div className="form-row">
<FormField label="Datum"><DateInput value={honorarForm.date} onChange={e => setHonorarForm(f => ({ ...f, date: e.target.value }))} /></FormField>
<FormField label="Betrag (CHF)"><input type="number" min="0" step="100" value={honorarForm.amount} onChange={e => setHonorarForm(f => ({ ...f, amount: e.target.value }))} placeholder="0" /></FormField>
</div>
<FormField label="Beschrieb"><input value={honorarForm.description} onChange={e => setHonorarForm(f => ({ ...f, description: e.target.value }))} placeholder="z.B. Elektroplanung Rohbau" /></FormField>
<FormField label="Phase"><input value={honorarForm.phase} onChange={e => setHonorarForm(f => ({ ...f, phase: e.target.value }))} placeholder="z.B. Phase 3133" /></FormField>
<FormField label="Bemerkung"><input value={honorarForm.note} onChange={e => setHonorarForm(f => ({ ...f, note: e.target.value }))} /></FormField>
</Modal>
)}
{/* Edit modal */}
{firmModal && (
<Modal title="Kontakt bearbeiten" onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(false)}
</Modal>
)}
</div>
);
}
// ── List view ──
const allTypes = [...new Set(contacts.map(c => c.type).filter(Boolean))].sort();
const filtered = contacts
.filter(c =>
(!typeFilter || c.type === typeFilter) &&
(!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
(c.type || "").toLowerCase().includes(search.toLowerCase()) ||
(c.contacts || []).some(p => p.name.toLowerCase().includes(search.toLowerCase())))
)
.sort((a, b) => a.name.localeCompare(b.name, "de"));
const contactGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filtered }];
if (groupBy === "alpha") {
const g = {};
filtered.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "type") {
const g = {};
filtered.forEach(c => { const k = c.type || "Ohne Typ"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ContactTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firma</th>
<th style={{ width: 140 }}>Typ</th>
<th style={{ width: 160 }}>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ width: 80, textAlign: "center" }}>Personen</th>
<th style={{ width: 80, textAlign: "center" }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const persons = c.contacts || [];
const haupt = persons[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
const projCount = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === c.id)).length;
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>{c.type || <span style={{ color: "#ccc" }}></span>}</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{haupt ? (
<>
<div style={{ fontWeight: 500 }}>{haupt.name}</div>
{haupt.position && <div style={{ fontSize: 11, color: "#888" }}>{haupt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{persons.length || "—"}</td>
<td style={{ textAlign: "center", fontSize: 12, color: projCount > 0 ? "#1a4e8a" : "#ccc", fontWeight: projCount > 0 ? 600 : 400 }}>{projCount || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delFirm(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kontakte" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kontakt</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen…"
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
{allTypes.length > 0 && (
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{ fontSize: 12, minWidth: 160 }}>
<option value="">Alle Typen</option>
{allTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
)}
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 160 }}>
<option value="alpha">Alphabetisch</option>
<option value="type">Nach Typ</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{contacts.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kontakte erfasst.</div>
) : filtered.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : contactGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ContactTable items={group.items} />
</div>
))}
{firmModal && (
<Modal title={firmModal.id ? "Kontakt bearbeiten" : "Neuer Kontakt"} onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(!firmModal.id)}
</Modal>
)}
</div>
);
}
+245 -12
View File
@@ -1,4 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { storage, isCloudBackend, isServerMode } from "../storage/adapter.js";
const isValidEmail = (s) => /.+@.+\..+/.test(s);
// Simple per-tab rate limit: after MAX_ATTEMPTS failed tries, lock for LOCK_MS. // Simple per-tab rate limit: after MAX_ATTEMPTS failed tries, lock for LOCK_MS.
const MAX_ATTEMPTS = 5; const MAX_ATTEMPTS = 5;
@@ -13,15 +16,47 @@ function writeAttempts(state) {
try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(state)); } catch {} try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(state)); } catch {}
} }
export default function Login({ verifyLogin, settings, version }) { export default function Login({ verifyLogin, settings, version, cloudUnreachable = false }) {
// Backend-Modus aus localStorage (per-Device, ähnlich Dark Mode).
// Beim Wechsel wird die App neu geladen, damit der Storage-Adapter neu initialisiert.
const [backend, setBackend] = useState(() => localStorage.getItem("rapport_backend") || "local");
const [cloudUrl, setCloudUrl] = useState(() => localStorage.getItem("rapport_cloud_url") || "");
const [editUrl, setEditUrl] = useState(() => {
const stored = localStorage.getItem("rapport_cloud_url") || "";
return (localStorage.getItem("rapport_backend") === "cloud") && !stored;
});
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [shake, setShake] = useState(false); const [shake, setShake] = useState(false);
const [lockedUntil, setLockedUntil] = useState(() => readAttempts().lockedUntil); const [lockedUntil, setLockedUntil] = useState(() => readAttempts().lockedUntil);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const submittingRef = useRef(false); const submittingRef = useRef(false);
// Cloud: Studios der Instanz für Multi-Studio-Dropdown
const [studios, setStudios] = useState([]);
const [selectedStudioId, setSelectedStudioId] = useState("");
// Passwort-Vergessen-Inline-Modus
const [forgotOpen, setForgotOpen] = useState(false);
const [forgotSent, setForgotSent] = useState(false);
const [forgotErr, setForgotErr] = useState("");
const isCloud = backend === "cloud";
useEffect(() => {
if (!isCloudBackend || !cloudUrl) return;
let cancelled = false;
(async () => {
const list = await storage.listStudios?.();
if (cancelled) return;
setStudios(list || []);
if (list?.length === 1) setSelectedStudioId(list[0].id);
})();
return () => { cancelled = true; };
}, [cloudUrl]);
// Tick once a second while locked, so the countdown updates and unlocks automatically // Tick once a second while locked, so the countdown updates and unlocks automatically
useEffect(() => { useEffect(() => {
if (!lockedUntil || lockedUntil <= now) return; if (!lockedUntil || lockedUntil <= now) return;
@@ -32,12 +67,34 @@ export default function Login({ verifyLogin, settings, version }) {
const isLocked = lockedUntil > now; const isLocked = lockedUntil > now;
const remainingSec = isLocked ? Math.ceil((lockedUntil - now) / 1000) : 0; const remainingSec = isLocked ? Math.ceil((lockedUntil - now) / 1000) : 0;
const switchBackend = (next) => {
if (next === backend) return;
localStorage.setItem("rapport_backend", next);
window.location.reload();
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (isLocked || submittingRef.current) return; if (isLocked || submittingRef.current) return;
submittingRef.current = true; submittingRef.current = true;
try { try {
const user = await Promise.resolve(verifyLogin(username, password)); // Cloud: URL muss gesetzt sein, sonst kann der Adapter nicht initialisiert worden sein
if (isCloud) {
const trimmed = (cloudUrl || "").trim().replace(/\/+$/, "");
if (!trimmed) {
setError(true);
setErrorMsg("Server-Adresse eingeben.");
return;
}
const currentUrl = localStorage.getItem("rapport_cloud_url") || "";
if (trimmed !== currentUrl) {
localStorage.setItem("rapport_cloud_url", trimmed);
window.location.reload();
return;
}
}
const user = await Promise.resolve(verifyLogin(username, password, { studioId: selectedStudioId || null }));
if (user) { if (user) {
writeAttempts({ count: 0, lockedUntil: 0 }); writeAttempts({ count: 0, lockedUntil: 0 });
} else { } else {
@@ -50,6 +107,7 @@ export default function Login({ verifyLogin, settings, version }) {
setLockedUntil(next.lockedUntil); setLockedUntil(next.lockedUntil);
setNow(Date.now()); setNow(Date.now());
setError(true); setError(true);
setErrorMsg(isCloud ? "Anmeldung fehlgeschlagen." : "Falscher Benutzername oder Passwort");
setShake(true); setShake(true);
setTimeout(() => setShake(false), 500); setTimeout(() => setShake(false), 500);
} }
@@ -58,7 +116,22 @@ export default function Login({ verifyLogin, settings, version }) {
} }
}; };
const studioName = settings?.name || "Studio"; const studioHeader = settings?.name || "Studio";
const userLabel = isCloud ? "EMAIL" : "BENUTZER";
const userPlaceholder = isCloud ? "name@studio.ch" : "admin";
const userInputType = isCloud ? "email" : "text";
// Im Server-Modus (gehostete Web-GUI) gibt es keine Server-Adress-Eingabe —
// die App ist fest an diesen Server gebunden.
const showUrlField = isCloud && editUrl && !isServerMode;
const showUrlBadge = isCloud && !editUrl && cloudUrl && !isServerMode;
// Hostname zur Anzeige (ohne Protokoll, ohne Port falls Standard)
let urlDisplay = cloudUrl;
try {
const u = new URL(cloudUrl);
urlDisplay = u.host;
} catch {}
return ( return (
<div style={{ <div style={{
@@ -120,12 +193,36 @@ export default function Login({ verifyLogin, settings, version }) {
background: #c4bbb0; background: #c4bbb0;
cursor: not-allowed; cursor: not-allowed;
} }
.backend-select {
background: transparent;
border: none;
color: #6a6660;
font-family: 'DM Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 18px 2px 4px;
margin: 0;
cursor: pointer;
outline: none;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path d='M1 1l3 3 3-3' stroke='%236a6660' stroke-width='1.2' fill='none' stroke-linecap='round'/></svg>");
background-repeat: no-repeat;
background-position: right 4px center;
}
.backend-select:hover { color: #1a1a18; }
.url-edit-link {
background: none; border: none; padding: 0;
color: #9a7858; font-family: inherit; font-size: 10px;
letter-spacing: 0.08em; cursor: pointer; text-decoration: underline;
}
.url-edit-link:hover { color: #1a1a18; }
`}</style> `}</style>
<div className={`login-card${shake ? " shake" : ""}`} style={{ <div className={`login-card${shake ? " shake" : ""}`} style={{
background: "#fdfcfa", background: "#fdfcfa",
borderRadius: 20, borderRadius: 20,
padding: "48px 44px 40px", padding: "48px 44px 28px",
width: "100%", maxWidth: 380, width: "100%", maxWidth: 380,
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)", boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
border: "1px solid #ddd8d0", border: "1px solid #ddd8d0",
@@ -136,29 +233,55 @@ export default function Login({ verifyLogin, settings, version }) {
RAPPORT RAPPORT
</div> </div>
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}> <div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>
{studioName.toUpperCase()} {studioHeader.toUpperCase()}
</div> </div>
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} /> <div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
</div> </div>
{cloudUnreachable && (
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center", lineHeight: 1.5 }}>
Server nicht erreichbar. Bitte Verbindung prüfen und neu laden.
</div>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{isCloud && studios.length > 1 && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}> <label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
BENUTZER STUDIO
</label>
<select
className={`login-input${error ? " error" : ""}`}
disabled={isLocked}
value={selectedStudioId}
onChange={e => { setSelectedStudioId(e.target.value); setError(false); }}
style={{ appearance: "none", paddingRight: 32, backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>\")", backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center" }}
>
<option value="">Studio auswählen</option>
{studios.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
{userLabel}
</label> </label>
<input <input
className={`login-input${error ? " error" : ""}`} className={`login-input${error ? " error" : ""}`}
type="text" type={userInputType}
autoComplete="username" autoComplete={isCloud ? "email" : "username"}
autoFocus autoFocus
disabled={isLocked} disabled={isLocked}
value={username} value={username}
onChange={e => { setUsername(e.target.value); setError(false); }} onChange={e => { setUsername(e.target.value); setError(false); }}
placeholder="admin" placeholder={userPlaceholder}
/> />
</div> </div>
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: showUrlField ? 14 : 28 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}> <label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
PASSWORT PASSWORT
</label> </label>
@@ -173,13 +296,29 @@ export default function Login({ verifyLogin, settings, version }) {
/> />
</div> </div>
{showUrlField && (
<div style={{ marginBottom: 28 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
SERVER-ADRESSE
</label>
<input
className="login-input"
type="url"
disabled={isLocked}
value={cloudUrl}
onChange={e => { setCloudUrl(e.target.value); setError(false); }}
placeholder="http://mac-mini.local:54321"
/>
</div>
)}
{isLocked ? ( {isLocked ? (
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fdf2f2", borderRadius: 8, border: "1px solid #e0b0b0", fontSize: 11, color: "#8a1a1a", textAlign: "center" }}> <div style={{ marginBottom: 18, padding: "9px 14px", background: "#fdf2f2", borderRadius: 8, border: "1px solid #e0b0b0", fontSize: 11, color: "#8a1a1a", textAlign: "center" }}>
Zu viele Fehlversuche. Bitte {remainingSec}s warten. Zu viele Fehlversuche. Bitte {remainingSec}s warten.
</div> </div>
) : error && ( ) : error && (
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}> <div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}>
Falscher Benutzername oder Passwort {errorMsg || (isCloud ? "Anmeldung fehlgeschlagen." : "Falscher Benutzername oder Passwort")}
</div> </div>
)} )}
@@ -188,7 +327,101 @@ export default function Login({ verifyLogin, settings, version }) {
</button> </button>
</form> </form>
<div style={{ marginTop: 24, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}> {/* Passwort vergessen — nur Cloud */}
{isCloud && !isLocked && !forgotOpen && !forgotSent && (
<div style={{ marginTop: 14, textAlign: "center", fontSize: 10 }}>
<button type="button" className="url-edit-link" onClick={() => { setForgotOpen(true); setForgotErr(""); }}>
Passwort vergessen?
</button>
</div>
)}
{isCloud && forgotOpen && !forgotSent && (
<div style={{ marginTop: 18, padding: "14px 14px 12px", background: "#f7f4f0", border: "1px solid #ddd8d0", borderRadius: 10 }}>
<div style={{ fontSize: 11, color: "#666", marginBottom: 10, lineHeight: 1.5 }}>
Wir senden Ihnen eine Email mit einem Link zum Zurücksetzen des Passworts.
</div>
<input
className="login-input"
type="email"
placeholder="name@studio.ch"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ marginBottom: 8 }}
/>
{forgotErr && <div style={{ fontSize: 11, color: "#b5621e", marginBottom: 8 }}>{forgotErr}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className="login-btn"
style={{ flex: 1, padding: "9px", fontSize: 12 }}
onClick={async () => {
if (!isValidEmail(username)) { setForgotErr("Bitte gültige Email eingeben."); return; }
const res = await storage.requestPasswordReset?.(username.trim());
if (res?.ok) { setForgotSent(true); setForgotErr(""); }
else setForgotErr(res?.error || "Fehler beim Versand.");
}}
>
Link senden
</button>
<button
type="button"
className="login-btn"
style={{ flex: 1, padding: "9px", fontSize: 12, background: "transparent", color: "#888", border: "1px solid #ddd8d0" }}
onClick={() => { setForgotOpen(false); setForgotErr(""); }}
>
Abbrechen
</button>
</div>
</div>
)}
{isCloud && forgotSent && (
<div style={{ marginTop: 14, padding: "10px 14px", background: "#e8f5ee", border: "1px solid #b8dbc4", borderRadius: 8, fontSize: 11, color: "#2d6a4f", textAlign: "center", lineHeight: 1.5 }}>
Email gesendet bitte Ihren Posteingang prüfen.
</div>
)}
{/* Verbindung-Switch + Server-Anzeige (dezent darunter) */}
{/* Verbindungs-Switch (Lokal/Server + Server-Adresse) nur in der lokalen
DMG. Die gehostete Web-GUI (Server-Modus) ist fest an diesen Server
gebunden — keine Wahl, kein Wechsel. */}
{!isServerMode && (
<div style={{
marginTop: 22, paddingTop: 16,
borderTop: "1px solid #ebe7e1",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 8, flexWrap: "wrap",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.12em", textTransform: "uppercase" }}>
Verbindung
</span>
<select
className="backend-select"
value={backend}
onChange={e => switchBackend(e.target.value)}
>
<option value="local">Lokal</option>
<option value="cloud">Cloud</option>
</select>
</div>
{showUrlBadge && (
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 10, color: "#8c8880", letterSpacing: "0.05em" }}>
<span title={cloudUrl}>{urlDisplay}</span>
<button
type="button"
className="url-edit-link"
onClick={() => setEditUrl(true)}
>
ändern
</button>
</div>
)}
</div>
)}
<div style={{ marginTop: 16, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}>
{version ? `V${version}` : ""} {version ? `V${version}` : ""}
</div> </div>
</div> </div>
+6 -11
View File
@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { SIA_PHASES, SIA_PHASE_WEIGHTS, STORAGE_KEY } from "../constants.js"; import { SIA_PHASES, SIA_PHASE_WEIGHTS } from "../constants.js";
import { calcSIAHours, calcManualHours, generateId, formatCHF, formatDate, formatHours, roundCHF, applyProjectNumberFormat, migrateLinkedQuotes, deriveQuoteBudget } from "../utils.js"; import { calcSIAHours, calcManualHours, generateId, formatCHF, formatDate, formatHours, roundCHF, applyProjectNumberFormat, migrateLinkedQuotes, deriveQuoteBudget } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, StatusSelect, useConfirm , DateInput } from "../components/UI.jsx"; import { Header, Modal, FormField, StatusBadge, StatusSelect, useConfirm , DateInput } from "../components/UI.jsx";
export default export default
function Quotes({ data, update, setData, saveAll, modal, setModal, setPrintContent, setView, onSelectProject }) { function Quotes({ data, update, saveAll, modal, setModal, setPrintContent, setView, onSelectProject }) {
const clients = (data.persons || []).filter(p => p.isAuftraggeber); const clients = (data.persons || []).filter(p => p.isAuftraggeber);
const roles = data.settings.roles || []; const roles = data.settings.roles || [];
const defaultRolesForPhase = () => { const defaultRolesForPhase = () => {
@@ -250,17 +250,12 @@ function Quotes({ data, update, setData, saveAll, modal, setModal, setPrintConte
sub, subAfterDisc: sub, globalDisc: 0, tax: t, total: roundCHF(sub+t), createdAt: new Date().toISOString(), sub, subAfterDisc: sub, globalDisc: 0, tax: t, total: roundCHF(sub+t), createdAt: new Date().toISOString(),
}; };
// Beide Updates atomar // Beide Updates atomar via saveAll (geht durch den Storage-Adapter)
setData(prev => { const updatedInvoices = [...data.invoices, newInv];
const updatedInvoices = [...prev.invoices, newInv]; const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? {
const updatedQuotes = (prev.quotes || []).map(x => x.id === q.id ? {
...x, status: mode === "schluss" ? "angenommen" : x.status, ...x, status: mode === "schluss" ? "angenommen" : x.status,
} : x); } : x);
const next = { ...prev, invoices: updatedInvoices, quotes: updatedQuotes }; saveAll({ ...data, invoices: updatedInvoices, quotes: updatedQuotes });
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
}; };
const convertToInvoice = (q) => { const convertToInvoice = (q) => {
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from "react";
// Empfangsseite des Passwort-Reset-Links. App.jsx zeigt diese Komponente,
// sobald Supabase den `PASSWORD_RECOVERY`-Event sendet. Der Reset-Token ist
// dann bereits im Auth-Client geparsed; wir setzen nur noch das neue Passwort.
export default function ResetPassword({ onComplete, onCancel }) {
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const submit = async () => {
if (pw1.length < 6) { setErr("Mindestens 6 Zeichen."); return; }
if (pw1 !== pw2) { setErr("Passwörter stimmen nicht überein."); return; }
setBusy(true); setErr("");
try {
const res = await onComplete(pw1);
if (res?.ok) setDone(true);
else setErr(res?.error || "Konnte nicht gespeichert werden.");
} finally { setBusy(false); }
};
return (
<div style={{
minHeight: "100vh", minWidth: "100vw",
background: "#ebe7e1",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'DM Mono', 'Courier New', monospace",
position: "fixed", inset: 0, zIndex: 9999,
}}>
<div style={{
background: "#fdfcfa", borderRadius: 20, padding: "48px 44px 32px",
width: "100%", maxWidth: 400, margin: "0 20px",
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
border: "1px solid #ddd8d0",
}}>
<div style={{ textAlign: "center", marginBottom: 30 }}>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 32, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>RAPPORT</div>
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>NEUES PASSWORT</div>
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
</div>
{done ? (
<>
<div style={{ padding: "12px 14px", background: "#e8f5ee", border: "1px solid #b8dbc4", borderRadius: 8, fontSize: 12, color: "#2d6a4f", textAlign: "center", marginBottom: 16, lineHeight: 1.5 }}>
Passwort aktualisiert. Sie können sich jetzt mit dem neuen Passwort anmelden.
</div>
<button onClick={onCancel} style={{ width: "100%", padding: 13, background: "#1a1a18", color: "#f0ede8", border: "none", borderRadius: 10, fontFamily: "inherit", fontSize: 13, cursor: "pointer" }}>
Zur Anmeldung
</button>
</>
) : (
<>
<p style={{ fontSize: 12, color: "#666", marginBottom: 18, lineHeight: 1.5, textAlign: "center" }}>
Bitte vergeben Sie ein neues Passwort.
</p>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 6, fontWeight: 500 }}>NEUES PASSWORT</label>
<input
type="password" autoFocus value={pw1} onChange={e => { setPw1(e.target.value); setErr(""); }}
style={{ width: "100%", boxSizing: "border-box", background: "#f7f4f0", border: "1.5px solid #ddd8d0", borderRadius: 10, padding: "11px 14px", fontFamily: "inherit", fontSize: 13, outline: "none" }}
placeholder="Mindestens 6 Zeichen"
/>
</div>
<div style={{ marginBottom: err ? 12 : 24 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 6, fontWeight: 500 }}>BESTÄTIGEN</label>
<input
type="password" value={pw2} onChange={e => { setPw2(e.target.value); setErr(""); }}
onKeyDown={e => e.key === "Enter" && submit()}
style={{ width: "100%", boxSizing: "border-box", background: "#f7f4f0", border: "1.5px solid #ddd8d0", borderRadius: 10, padding: "11px 14px", fontFamily: "inherit", fontSize: 13, outline: "none" }}
placeholder="Nochmals eingeben"
/>
</div>
{err && <div style={{ marginBottom: 16, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}>{err}</div>}
<button onClick={submit} disabled={busy} style={{ width: "100%", padding: 13, background: busy ? "#c4bbb0" : "#1a1a18", color: "#f0ede8", border: "none", borderRadius: 10, fontFamily: "inherit", fontSize: 13, cursor: busy ? "default" : "pointer" }}>
{busy ? "Wird gespeichert …" : "Passwort speichern"}
</button>
<button onClick={onCancel} style={{ width: "100%", marginTop: 10, padding: 10, background: "transparent", color: "#888", border: "1px solid #ddd8d0", borderRadius: 10, fontFamily: "inherit", fontSize: 12, cursor: "pointer" }}>
Abbrechen
</button>
</>
)}
</div>
</div>
);
}
+233 -5
View File
@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { STORAGE_KEY, DEFAULT_ABSENZ_TYPES } from "../constants.js"; import { DEFAULT_ABSENZ_TYPES } from "../constants.js";
import { storage, isCloudBackend } from "../storage/adapter.js";
import { formatIban, isQRIban, applyProjectNumberFormat, applyProtoNumberFormat, generateId, getFeiertageForYear, getAbsenzTypes } from "../utils.js"; import { formatIban, isQRIban, applyProjectNumberFormat, applyProtoNumberFormat, generateId, getFeiertageForYear, getAbsenzTypes } from "../utils.js";
import { Header, FormField, Modal, DateInput, useConfirm } from "../components/UI.jsx"; import { Header, FormField, Modal, DateInput, useConfirm } from "../components/UI.jsx";
import UpdatesSupport from "../components/UpdatesSupport.jsx"; import UpdatesSupport from "../components/UpdatesSupport.jsx";
@@ -213,6 +214,17 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
const [ftForm, setFtForm] = useState({ date: "", label: "", stundenDelta: 0, repeatsYearly: true }); const [ftForm, setFtForm] = useState({ date: "", label: "", stundenDelta: 0, repeatsYearly: true });
const [absenzTypeForm, setAbsenzTypeForm] = useState({ label: "", color: "#555" }); const [absenzTypeForm, setAbsenzTypeForm] = useState({ label: "", color: "#555" });
const [kalModal, setKalModal] = useState(null); const [kalModal, setKalModal] = useState(null);
// Cloud-spezifisch: Studios, in denen der aktuelle User Mitglied ist (für Switcher + Sharing)
const [myStudios, setMyStudios] = useState([]);
const [newStudioModal, setNewStudioModal] = useState(null); // null | { name, shareFrom: Set<id> }
const [cloudUrl] = useState(() => localStorage.getItem("rapport_cloud_url") || "");
const [activeStudioId] = useState(() => sessionStorage.getItem("rapport_studio_id") || "");
const [inviteModal, setInviteModal] = useState(null); // null | { email, displayName, appRoleId, tempPassword }
const [inviteResult, setInviteResult] = useState(null); // {ok, error, tempPassword, email}
useEffect(() => {
if (!isCloudBackend) return;
(async () => { setMyStudios(await storage.myStudios?.() || []); })();
}, []);
const { askConfirm, ConfirmModalEl } = useConfirm(); const { askConfirm, ConfirmModalEl } = useConfirm();
const isDirty = JSON.stringify(s) !== JSON.stringify(data.settings); const isDirty = JSON.stringify(s) !== JSON.stringify(data.settings);
const isAdmin = !currentUser || currentUser.role === "admin"; const isAdmin = !currentUser || currentUser.role === "admin";
@@ -253,7 +265,7 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
try { try {
const imported = JSON.parse(evt.target.result); const imported = JSON.parse(evt.target.result);
if (await askConfirm("Aktuelle Daten wirklich überschreiben?", "Überschreiben")) { if (await askConfirm("Aktuelle Daten wirklich überschreiben?", "Überschreiben")) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(imported)); await storage.save(imported);
window.location.reload(); window.location.reload();
} }
} catch { } catch {
@@ -350,7 +362,7 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
<button className="btn btn-danger" style={{ width: "100%", marginBottom: 10 }} onClick={async () => { <button className="btn btn-danger" style={{ width: "100%", marginBottom: 10 }} onClick={async () => {
setInitModal(false); setInitModal(false);
if (!await askConfirm("Wirklich initialisieren? Alle Daten gehen verloren.", "Initialisieren")) return; if (!await askConfirm("Wirklich initialisieren? Alle Daten gehen verloren.", "Initialisieren")) return;
localStorage.removeItem(STORAGE_KEY); await storage.clear();
window.location.reload(); window.location.reload();
}}>Trotzdem initialisieren</button> }}>Trotzdem initialisieren</button>
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={() => setInitModal(false)}>Abbrechen</button> <button className="btn btn-ghost" style={{ width: "100%" }} onClick={() => setInitModal(false)}>Abbrechen</button>
@@ -601,6 +613,25 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
{tab === "team" && ( {tab === "team" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2"> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{isCloudBackend && isAdmin && (
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>MITARBEITER (CLOUD)</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => setInviteModal({ email: "", displayName: "", appRoleId: "r-mitarbeiter", tempPassword: Math.random().toString(36).slice(2, 10) })}>+ Einladen</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>
Personen mit Cloud-Zugang zu diesem Studio. Beim Einladen vergeben Sie ein temporäres Passwort, das Sie der Person separat mitteilen.
</div>
{(data.users || []).map(u => (
<div key={u.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #f0ede8", fontSize: 13 }}>
<div>
<div style={{ fontWeight: 500 }}>{u.displayName || u.username}</div>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.04em" }}>{u.username} · {u.role === "admin" ? "Admin" : (data.appRoles || []).find(r => r.id === u.appRoleId)?.name || u.appRoleId}</div>
</div>
</div>
))}
</div>
)}
<div className="card"> <div className="card">
<Section title="MITARBEITER-STANDARDS"> <Section title="MITARBEITER-STANDARDS">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>Vorausgefüllt bei neuen Mitarbeitern.</div> <div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>Vorausgefüllt bei neuen Mitarbeitern.</div>
@@ -818,18 +849,63 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
{tab === "system" && ( {tab === "system" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2"> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card"> <div className="card">
{isCloudBackend && (
<Section title="CLOUD-VERBINDUNG">
<div style={{ fontSize: 12, color: "#888", marginBottom: 14, lineHeight: 1.7 }}>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid #f0ede8" }}>
<span>Server</span><span style={{ color: "#1a1a18", fontFamily: "monospace", fontSize: 11 }}>{(() => { try { return new URL(cloudUrl).host; } catch { return cloudUrl; } })()}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid #f0ede8" }}>
<span>Aktives Studio</span><span style={{ color: "#1a1a18", fontWeight: 500 }}>{myStudios.find(s => s.id === activeStudioId)?.name || "—"}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span>Studios des Accounts</span><strong style={{ color: "#555" }}>{myStudios.length}</strong>
</div>
</div>
{isAdmin && (
<button
className="btn btn-ghost"
style={{ width: "100%", marginBottom: 6 }}
onClick={() => setNewStudioModal({ name: "", shareFrom: new Set() })}
>
+ Weiteres Studio anlegen
</button>
)}
{myStudios.length > 1 && (
<div style={{ marginTop: 10 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.12em", color: "#8c8880", marginBottom: 6, fontWeight: 500 }}>STUDIO WECHSELN</label>
<select
className="login-input"
value={activeStudioId}
onChange={e => {
sessionStorage.setItem("rapport_studio_id", e.target.value);
window.location.reload();
}}
>
{myStudios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
</Section>
)}
<Section title="DATEN & BACKUP"> <Section title="DATEN & BACKUP">
<p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}> <p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}>
Alle Daten liegen ausschliesslich im Browser (localStorage). Regelmässige Backups sind empfohlen. {isCloudBackend
? "Daten liegen in der Cloud. Backup zieht den aktuellen Snapshot als JSON-Datei — z.B. für Archiv oder Migration."
: "Alle Daten liegen ausschliesslich im Browser (localStorage). Regelmässige Backups sind empfohlen."}
</p> </p>
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup als JSON herunterladen</button> <button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup als JSON herunterladen</button>
{!isCloudBackend && (
<label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}> <label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}>
Backup importieren Backup importieren
<input type="file" accept=".json" onChange={importData} style={{ display: "none" }} /> <input type="file" accept=".json" onChange={importData} style={{ display: "none" }} />
</label> </label>
)}
{!isCloudBackend && (
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}> <button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}>
Neu initialisieren Neu initialisieren
</button> </button>
)}
</Section> </Section>
<Section title="DATENBANKÜBERSICHT"> <Section title="DATENBANKÜBERSICHT">
@@ -864,6 +940,158 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
{/* ── Tab: Updates & Support ── */} {/* ── Tab: Updates & Support ── */}
{tab === "support" && <UpdatesSupport />} {tab === "support" && <UpdatesSupport />}
</>} </>}
{/* Cloud: Mitarbeiter einladen Modal */}
{inviteModal && (
<Modal onClose={() => setInviteModal(null)}>
<h3 style={{ marginBottom: 6, fontFamily: "'Playfair Display', serif", fontSize: 22 }}>Mitarbeiter einladen</h3>
<p style={{ fontSize: 12, color: "#888", marginBottom: 18, lineHeight: 1.6 }}>
Konto wird angelegt und der Person als Studio-Mitglied zugeordnet. Das temporäre Passwort teilen Sie der Person separat mit.
</p>
<FormField label="EMAIL">
<input
type="email"
autoFocus
value={inviteModal.email}
onChange={e => setInviteModal(m => ({ ...m, email: e.target.value }))}
placeholder="name@studio.ch"
/>
</FormField>
<FormField label="NAME">
<input
type="text"
value={inviteModal.displayName}
onChange={e => setInviteModal(m => ({ ...m, displayName: e.target.value }))}
placeholder="Anna Beispiel"
/>
</FormField>
<FormField label="APP-ROLLE">
<select
value={inviteModal.appRoleId}
onChange={e => setInviteModal(m => ({ ...m, appRoleId: e.target.value }))}
>
{(data.appRoles || []).map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</FormField>
<FormField label="TEMPORÄRES PASSWORT">
<input
type="text"
value={inviteModal.tempPassword}
onChange={e => setInviteModal(m => ({ ...m, tempPassword: e.target.value }))}
/>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>
Wird automatisch generiert. Person kann es nach erstem Login ändern.
</div>
</FormField>
<div style={{ display: "flex", gap: 10, marginTop: 24 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setInviteModal(null)}>Abbrechen</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
disabled={!inviteModal.email.trim() || !inviteModal.displayName.trim() || inviteModal.tempPassword.length < 6}
onClick={async () => {
const m = inviteModal;
const res = await storage.inviteMember(m.email.trim(), m.tempPassword, m.displayName.trim(), m.appRoleId);
if (res?.ok) {
setInviteResult({ ok: true, email: m.email.trim(), tempPassword: m.tempPassword });
setInviteModal(null);
} else {
alert("Einladen fehlgeschlagen: " + (res?.error || "unbekannt"));
}
}}
>
Einladen
</button>
</div>
</Modal>
)}
{/* Cloud: Erfolgs-Bestätigung mit Credentials zum Weitergeben */}
{inviteResult?.ok && (
<Modal onClose={() => setInviteResult(null)}>
<h3 style={{ marginBottom: 6, fontFamily: "'Playfair Display', serif", fontSize: 22 }}>Eingeladen </h3>
<p style={{ fontSize: 13, color: "#666", marginBottom: 18, lineHeight: 1.6 }}>
Bitte teilen Sie diese Zugangsdaten der Person mit. Sie kann sich damit anmelden und das Passwort danach selbst ändern.
</p>
<div style={{ background: "#faf8f5", border: "1px solid #ece8e2", borderRadius: 8, padding: 16, marginBottom: 16, fontFamily: "monospace", fontSize: 13 }}>
<div style={{ marginBottom: 8 }}><span style={{ color: "#aaa", fontSize: 10, letterSpacing: "0.1em" }}>EMAIL</span><br />{inviteResult.email}</div>
<div><span style={{ color: "#aaa", fontSize: 10, letterSpacing: "0.1em" }}>PASSWORT</span><br />{inviteResult.tempPassword}</div>
</div>
<button className="btn btn-primary" style={{ width: "100%" }} onClick={() => setInviteResult(null)}>Schliessen</button>
</Modal>
)}
{/* Cloud: Neues Studio anlegen Modal */}
{newStudioModal && (
<Modal onClose={() => setNewStudioModal(null)}>
<h3 style={{ marginBottom: 6, fontFamily: "'Playfair Display', serif", fontSize: 22 }}>Weiteres Studio anlegen</h3>
<p style={{ fontSize: 12, color: "#888", marginBottom: 18, lineHeight: 1.6 }}>
Neues Studio auf derselben Cloud-Instanz. Sie werden Admin. Stammdaten (Rollen, Templates, Absenz-Typen) werden automatisch eingespielt.
</p>
<FormField label="STUDIO-NAME">
<input
type="text"
value={newStudioModal.name}
onChange={e => setNewStudioModal(m => ({ ...m, name: e.target.value }))}
placeholder="z.B. Studio Zürich-Nord"
autoFocus
/>
</FormField>
{myStudios.length > 0 && (
<div style={{ marginTop: 16 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.12em", color: "#8c8880", marginBottom: 8, fontWeight: 500 }}>
PERSONEN ÜBERNEHMEN VON
</label>
<p style={{ fontSize: 11, color: "#888", marginBottom: 10, lineHeight: 1.6 }}>
Ausgewählte Studios teilen ihre Personen (Kunden + Partner) mit dem neuen Studio. Änderungen sind danach für alle verlinkten Studios sichtbar.
</p>
<div style={{ border: "1px solid #ddd8d0", borderRadius: 8, padding: 4, maxHeight: 180, overflowY: "auto" }}>
{myStudios.map(s => (
<label key={s.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", cursor: "pointer", fontSize: 13, borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.background = "#f7f4f0"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
<input
type="checkbox"
checked={newStudioModal.shareFrom.has(s.id)}
onChange={e => setNewStudioModal(m => {
const next = new Set(m.shareFrom);
if (e.target.checked) next.add(s.id); else next.delete(s.id);
return { ...m, shareFrom: next };
})}
style={{ width: "auto" }}
/>
<span>{s.name}</span>
</label>
))}
</div>
</div>
)}
<div style={{ display: "flex", gap: 10, marginTop: 24 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setNewStudioModal(null)}>Abbrechen</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
disabled={!newStudioModal.name.trim()}
onClick={async () => {
const name = newStudioModal.name.trim();
const baseSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
const slug = `${baseSlug || "studio"}-${Date.now().toString(36)}`;
try {
const newId = await storage.createStudio(name, slug, Array.from(newStudioModal.shareFrom));
sessionStorage.setItem("rapport_studio_id", newId);
window.location.reload();
} catch (e) {
alert("Fehler beim Anlegen: " + e.message);
}
}}
>
Anlegen
</button>
</div>
</Modal>
)}
</div> </div>
); );
} }
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+415
View File
@@ -0,0 +1,415 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "APP"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:8080"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = [
"http://localhost:8080",
"http://localhost:8080/",
"http://localhost:5173",
"http://localhost:5173/",
"http://127.0.0.1:8080",
"http://127.0.0.1:5173",
]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
+761
View File
@@ -0,0 +1,761 @@
-- ============================================================================
-- RAPPORT — Initial Migration (Draft v1)
-- ============================================================================
-- Zweck: Übersetzt das localStorage-Schema (studio_data_v1) nach Postgres,
-- Multi-Tenant von Anfang an (jede Tabelle hat studio_id),
-- Auth via Supabase (auth.users), Files via Supabase Storage.
--
-- Stand: Draft auf Basis von constants.js + View-Audit (Projects, Quotes,
-- Invoices, Expenses, Employees, Persons, Protocols, Time).
-- Noch NICHT auf eine echte Supabase-Instanz angewendet.
--
-- Konventionen:
-- - snake_case für Spalten, camelCase-Felder aus dem Frontend werden
-- beim Adapter-Mapping übersetzt (z.B. ferienWochen → ferien_wochen).
-- - status/kind/mode als CHECK-Constraints (statt Postgres-ENUM), weil
-- leichter zu erweitern.
-- - Volatile / formverteilte Strukturen als JSONB (z.B. quote.sia,
-- protocol.participants), klassisch normalisiert wo joinable.
-- - RLS-Policies am Ende; bis dahin sind Tabellen offen — für Self-Hosted
-- Supabase muss `alter table … enable row level security` aktiv sein.
-- ============================================================================
-- ─── EXTENSIONS ────────────────────────────────────────────────────────────
create extension if not exists "pgcrypto"; -- gen_random_uuid()
create extension if not exists "citext"; -- case-insensitive email
-- ════════════════════════════════════════════════════════════════════════════
-- TENANT-LAYER: Studios + Membership
-- ════════════════════════════════════════════════════════════════════════════
create table studios (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
created_at timestamptz not null default now()
);
-- Verlinkung User (Supabase auth.users) ↔ Studio mit Rolle pro Studio
create table studio_members (
studio_id uuid not null references studios(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
app_role_id text, -- FK später, app_roles ist studio-spezifisch
active boolean not null default true,
joined_at timestamptz not null default now(),
primary key (studio_id, user_id)
);
-- Profil-Erweiterung zu auth.users (Anzeigename etc.) — global, nicht studio-spezifisch
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
username citext unique not null,
display_name text not null,
created_at timestamptz not null default now()
);
-- ════════════════════════════════════════════════════════════════════════════
-- STUDIO-WEITE STAMMDATEN (Settings, Rollen, Templates)
-- ════════════════════════════════════════════════════════════════════════════
-- Eine Zeile pro Studio (ersetzt das Singleton-`settings`-Objekt)
create table studio_settings (
studio_id uuid primary key references studios(id) on delete cascade,
name text not null default 'Mein Studio',
address text,
street text, zip text, city text, country text default 'CH',
email citext, phone text,
iban text,
iban_type text default 'qr' check (iban_type in ('qr','normal')),
mwst_nr text,
mwst_rate numeric(5,2) default 8.1,
default_hourly_rate numeric(10,2) default 120,
default_wochenstunden numeric(5,2) default 35,
default_ferien_wochen numeric(4,1) default 5,
-- Volatile / formverteilte Konfig als JSONB:
formats jsonb not null default '{}'::jsonb, -- projectNumberFormat, invoiceNumberFormat, …
page_margins jsonb not null default '{}'::jsonb, -- pageMarginTop/Bottom/Left/Right
ui jsonb not null default '{}'::jsonb, -- autoPrint, logoSize, qrNewPage, pdfNameFormat
protokoll_type_abbr jsonb not null default '{}'::jsonb, -- {"Bausitzung":"BS", …}
closed_months int[] default '{}',
block_mai_tag boolean default true,
setup_completed boolean default false,
logo_url text, -- Supabase Storage Pfad
updated_at timestamptz not null default now()
);
-- Rate-Profile (PL/TS/BL/AS) — pro Studio, weil Stundensätze unterschiedlich
create table studio_roles (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "PL", "TS", … (innerhalb Studio eindeutig)
label text not null,
rate numeric(10,2) not null default 0,
sort int default 0,
primary key (studio_id, id)
);
-- App-Rollen (Permissions/Dashboard-Zuordnung) — pro Studio
create table app_roles (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "r-admin", "r-projektleiter", …
name text not null,
permissions text[], -- null = alle; sonst ["dashboard","projects",…]
dashboard_template_id text,
primary key (studio_id, id)
);
create table dashboard_templates (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "tpl-admin", …
name text not null,
is_public boolean default true,
layout jsonb not null, -- Row-/Widget-Struktur
primary key (studio_id, id)
);
alter table app_roles
add constraint app_roles_dashboard_fk
foreign key (studio_id, dashboard_template_id)
references dashboard_templates(studio_id, id) on delete set null;
alter table studio_members
add constraint studio_members_role_fk
foreign key (studio_id, app_role_id)
references app_roles(studio_id, id) on delete set null;
-- Absenz-Typen (Krankheit/Unfall/…) — pro Studio
create table absence_types (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "krankheit", "unfall", …
label text not null,
color text,
primary key (studio_id, id)
);
-- Brieftemplates ("Offerte", "Zahlungserinnerung") — pro Studio
create table letter_templates (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "offer", "reminder"
name text not null,
body text not null,
primary key (studio_id, id)
);
-- Feiertage — pro Studio (kantonal-spezifisch)
create table holidays (
studio_id uuid not null references studios(id) on delete cascade,
date date not null,
label text not null,
half_day boolean default false,
primary key (studio_id, date)
);
-- ════════════════════════════════════════════════════════════════════════════
-- PERSONEN (Kunden + Partner vereint, seit v0.5)
-- ────────────────────────────────────────────────────────────────────────────
-- Zwei Modi pro Person:
-- a) Studio-lokal: studio_id IS NOT NULL → klassisch pro Studio
-- b) Geteilt: studio_id IS NULL → lebt in person_studio_links,
-- sichtbar in allen verlinkten Studios
-- Default ist (a). Umstellung auf (b) ist eine User-Aktion in Stammdaten.
-- ════════════════════════════════════════════════════════════════════════════
create table persons (
id uuid primary key default gen_random_uuid(),
studio_id uuid references studios(id) on delete cascade, -- NULL = geteilt
name text not null,
person_type text, -- Planer-Typ: "Elektroplaner", "HLKSE-Planer", …
is_auftraggeber boolean not null default false,
is_partner boolean not null default false,
street text, zip text, city text, country text default 'CH',
email citext, phone text, website text,
note text,
contacts jsonb not null default '[]'::jsonb, -- [{id,name,position,phone,email}]
honorar_offers jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
-- updated_at + created_by/updated_by werden im Audit-Block am Ende ergänzt
);
create index on persons (studio_id);
-- Sichtbarkeit für geteilte Personen (studio_id IS NULL).
-- Nur relevant, wenn ein User die Person später "globalisiert".
create table person_studio_links (
person_id uuid not null references persons(id) on delete cascade,
studio_id uuid not null references studios(id) on delete cascade,
primary_studio boolean default false, -- in welchem Studio wurde sie ursprünglich angelegt
linked_at timestamptz not null default now(),
primary key (person_id, studio_id)
);
create index on person_studio_links (studio_id);
-- ════════════════════════════════════════════════════════════════════════════
-- PROJEKTE
-- ════════════════════════════════════════════════════════════════════════════
create table projects (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text, -- "2025/03" via Format-Template
name text not null,
client_id uuid references persons(id) on delete set null,
category text, -- "Wettbewerb", "Direktauftrag", … (siehe constants.PROJECT_TYPES)
billing_type text check (billing_type in ('stundensatz','pauschal')),
hourly_rate numeric(10,2),
budget numeric(14,2),
budget_hours numeric(10,2),
status text default 'aktiv' check (status in ('aktiv','pausiert','abgeschlossen')),
description text,
start_date date,
-- Aktivierte SIA-Phasen als Array (z.B. ["31","32","41","51","52","53"])
enabled_phases text[] not null default '{}',
-- Komplexe / formverteilte Strukturen als JSONB (siehe View-Audit):
positions jsonb not null default '[]'::jsonb, -- [{phaseId, …}]
custom_phases jsonb not null default '[]'::jsonb, -- [{id, label}]
project_contacts jsonb not null default '[]'::jsonb, -- [{contactId, personIds[]}]
internal_members jsonb not null default '[]'::jsonb, -- [{userId, role, …}]
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on projects (studio_id, status);
create index on projects (client_id);
-- Projekt ↔ Offerten (mit Rolle aus linkedQuotes-Eintrag)
create table project_quote_links (
project_id uuid not null references projects(id) on delete cascade,
quote_id uuid not null, -- FK nach quotes (siehe unten)
role text,
primary key (project_id, quote_id)
);
-- ════════════════════════════════════════════════════════════════════════════
-- OFFERTEN
-- ════════════════════════════════════════════════════════════════════════════
create table quotes (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
client_id uuid references persons(id) on delete set null,
project_id uuid references projects(id) on delete set null,
project_name text, -- snapshot wenn Projekt nicht (mehr) verlinkt
date date,
valid_until date,
mode text check (mode in ('sia','manual','free')),
mwst boolean default true,
notes text,
status text default 'entwurf' check (status in
('entwurf','gesendet','angenommen','abgelehnt','abgelaufen')),
-- Drei Kalkulations-Pfade — je nach mode wird einer befüllt:
sia_config jsonb, -- {baukosten, schwierigkeit, stundenansatz, phases[]}
manual_phases jsonb, -- [{phaseId, …}]
free_items jsonb, -- [{id, desc, qty, price}]
quote_roles jsonb, -- [{id, label, rate}] — Rate-Overrides pro Offerte
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on quotes (studio_id, status);
create index on quotes (project_id);
alter table project_quote_links
add constraint project_quote_links_quote_fk
foreign key (quote_id) references quotes(id) on delete cascade;
-- ════════════════════════════════════════════════════════════════════════════
-- RECHNUNGEN
-- ════════════════════════════════════════════════════════════════════════════
create table invoices (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
client_id uuid references persons(id) on delete set null,
contact_id uuid, -- optional: Kontaktperson aus persons.contacts (JSONB)
project_id uuid references projects(id) on delete set null,
quote_id uuid references quotes(id) on delete set null,
date date,
due_date date,
sent_date date,
paid_date date, -- gesetzt wenn status = 'bezahlt'
items jsonb not null default '[]'::jsonb, -- [{id,desc,qty,price,discount}]
mwst boolean default true,
mwst_rate numeric(5,2),
notes text,
status text default 'entwurf' check (status in
('entwurf','gesendet','bezahlt','überfällig')),
invoice_kind text check (invoice_kind in ('akonto','teilrechnung','schluss','voll')),
discount_type text default 'none' check (discount_type in ('none','percent','amount')),
discount_value numeric(14,2) default 0,
discount_label text,
-- Welche Zeit-/Spesen-Einträge in diese Rechnung gehen
entry_selections jsonb not null default '{}'::jsonb,
qr_reference text, -- 27-stellige Schweizer QR-Ref
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on invoices (studio_id, status);
create index on invoices (project_id);
create index on invoices (client_id);
-- Mahnungen-Historie. Verifiziert: Frontend speichert inv.reminders[] mit
-- {nr, date, sentDate, daysPast}. Wird hier 1:1 abgebildet, damit jeder mit
-- Buchhaltungs-Zugriff den letzten Mahnungs-Stand sieht (z.B. "3× Mahnung,
-- zuletzt 15.03.2025"). UI-Hinweis-Box ("schick eine Mahnung") bleibt
-- localStorage — wie Dark Mode, per-Device-Setting.
create table invoice_reminders (
id uuid primary key default gen_random_uuid(),
invoice_id uuid not null references invoices(id) on delete cascade,
nr int not null check (nr between 1 and 9), -- 1. Erinnerung, 2./3. Mahnung
date date not null, -- Erstell-/Druckdatum
sent_date date, -- editierbar im Mahnungs-Modal
days_past int, -- Snapshot Tage überfällig zum Zeitpunkt
note text,
created_at timestamptz not null default now()
);
create index on invoice_reminders (invoice_id, nr);
-- ════════════════════════════════════════════════════════════════════════════
-- ZEITERFASSUNG
-- ════════════════════════════════════════════════════════════════════════════
create table time_entries (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid, -- FK nach employees (siehe unten)
project_id uuid references projects(id) on delete set null,
phase_id text, -- SIA-Phase z.B. "32"
position_id text, -- optional, sub-position
date date not null,
minutes int not null,
start_time time,
end_time time,
description text,
created_at timestamptz not null default now()
);
create index on time_entries (studio_id, date);
create index on time_entries (employee_id, date);
create index on time_entries (project_id, date);
-- ════════════════════════════════════════════════════════════════════════════
-- SPESEN & AUSGABEN
-- ════════════════════════════════════════════════════════════════════════════
create table expenses ( -- Mitarbeiterspesen (zur Rückerstattung)
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid, -- FK nach employees
project_id uuid references projects(id) on delete set null,
date date not null,
category text, -- aus studio_settings.expense_categories
description text,
amount numeric(14,2) not null,
mwst_rate numeric(5,2),
incl_mwst boolean default true,
status text default 'offen' check (status in
('offen','genehmigt','auf nächsten Lohn','ausbezahlt')),
receipt_url text, -- Supabase Storage Pfad (statt Base64)
receipt_name text,
lohn_entry_id uuid, -- FK nach payroll_entries (gesetzt bei ausbezahlt)
created_at timestamptz not null default now()
);
create index on expenses (studio_id, status);
create index on expenses (employee_id, date);
create table internal_expenses ( -- Studio-Ausgaben (Miete, Software, …)
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
date date not null,
category text,
description text,
amount numeric(14,2) not null,
mwst_rate numeric(5,2),
incl_mwst boolean default true,
recurring boolean default false,
recurring_interval text check (recurring_interval in ('monatlich','quartalsweise','jährlich')),
receipt_url text,
created_at timestamptz not null default now()
);
create index on internal_expenses (studio_id, date);
-- ════════════════════════════════════════════════════════════════════════════
-- MITARBEITER (HR)
-- ════════════════════════════════════════════════════════════════════════════
create table employees (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
name text not null,
personal_nr text,
pensum int check (pensum between 0 and 100), -- in Prozent
wochenstunden numeric(5,2),
ferien_wochen numeric(4,1),
pk_ag_satz numeric(5,2), -- Pensionskasse-AG-Satz
ferien_uebertrag_vorjahr jsonb not null default '{}'::jsonb, -- {year: days}
-- Brücke zu App-Login (optional — nicht jeder Mitarbeiter braucht Cloud-Account)
app_user_id uuid references profiles(id) on delete set null,
active boolean default true,
created_at timestamptz not null default now()
);
create index on employees (studio_id, active);
-- Jetzt die zirkulären FKs nachreichen:
alter table time_entries
add constraint time_entries_employee_fk
foreign key (employee_id) references employees(id) on delete set null;
alter table expenses
add constraint expenses_employee_fk
foreign key (employee_id) references employees(id) on delete set null;
create table absences (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
type_id text, -- FK auf absence_types(studio_id, id)
date date, -- Einzeltag-Variante
date_from date, -- Mehrtages-Variante
date_to date,
start_time time,
end_time time,
hours int,
minutes int,
note text,
status text default 'pending' check (status in ('pending','approved','rejected')),
created_at timestamptz not null default now(),
constraint absences_type_fk foreign key (studio_id, type_id)
references absence_types(studio_id, id) on delete set null
);
create index on absences (employee_id, date);
create table vacation_entries ( -- ferienEntries
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
date_from date not null,
date_to date not null,
note text,
status text default 'pending' check (status in ('pending','approved','rejected')),
original_data jsonb, -- für pending-Anträge: Snapshot der Eingabe
created_at timestamptz not null default now()
);
create index on vacation_entries (employee_id, date_from);
create table payroll_entries ( -- lohnEntries
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
year int not null,
month int not null check (month between 1 and 12),
brutto numeric(14,2),
ahv numeric(14,2),
alv numeric(14,2),
bvg numeric(14,2),
nbu numeric(14,2),
ktg numeric(14,2),
quellensteuer numeric(14,2),
spesen numeric(14,2),
bonus numeric(14,2),
netto numeric(14,2),
status text default 'entwurf',
paid_at date,
created_at timestamptz not null default now(),
unique (employee_id, year, month)
);
-- expenses.lohn_entry_id zeigt jetzt auf existierende Tabelle:
alter table expenses
add constraint expenses_lohn_entry_fk
foreign key (lohn_entry_id) references payroll_entries(id) on delete set null;
create table overtime_closings ( -- uberstundenAbschluss
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
date date not null,
saldo_hours numeric(8,2),
created_at timestamptz not null default now()
);
-- ════════════════════════════════════════════════════════════════════════════
-- DOKUMENTE: Protokolle, Lieferscheine
-- ════════════════════════════════════════════════════════════════════════════
create table protocols (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text, -- "2025-BS-01"
type text check (type in (
'Bausitzung','Planungssitzung','Baubesprechung','Jour fixe',
'Interne Sitzung','Kundensitzung','Abnahme','Sonstiges')),
location text,
project_id uuid references projects(id) on delete set null,
project_manual text, -- freie Eingabe falls kein verlinktes Projekt
participants jsonb not null default '[]'::jsonb, -- [{id,name,role,source,status}]
traktanden jsonb not null default '[]'::jsonb, -- [{id,nr,title,items:[{kind,text,…}]}]
next_date date,
verteiler text,
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on protocols (studio_id, type);
create index on protocols (project_id);
create table delivery_notes (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
date date,
client_id uuid references persons(id) on delete set null,
project_id uuid references projects(id) on delete set null,
notes text,
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create table delivery_note_items (
id uuid primary key default gen_random_uuid(),
delivery_note_id uuid not null references delivery_notes(id) on delete cascade,
sort int default 0,
description text,
qty numeric(12,3) default 1,
unit text default 'Stk.',
note text
);
create index on delivery_note_items (delivery_note_id);
-- ════════════════════════════════════════════════════════════════════════════
-- PINNWAND (Blog-Posts)
-- ════════════════════════════════════════════════════════════════════════════
create table blog_posts (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
author_id uuid references profiles(id) on delete set null,
category text,
title text,
body text,
pinned boolean default false,
created_at timestamptz not null default now()
);
create index on blog_posts (studio_id, created_at desc);
-- ════════════════════════════════════════════════════════════════════════════
-- AUDIT-SPALTEN: created_by / updated_by + Auto-Update via Trigger
-- ----------------------------------------------------------------------------
-- Bewusst nur auf "Daten"-Tabellen, nicht auf Stammdaten-Konfig (studio_roles,
-- app_roles, absence_types, holidays, letter_templates, dashboard_templates) —
-- die ändern sich selten, der Audit-Overhead lohnt nicht. Für Stammdaten
-- reicht `updated_at`.
--
-- Beim Insert/Update wird updated_at = now(), updated_by = auth.uid() gesetzt.
-- created_by wird beim Insert einmalig gesetzt und nie überschrieben.
-- ════════════════════════════════════════════════════════════════════════════
do $$
declare t text;
begin
for t in
select unnest(array[
'studios',
'persons','projects','quotes','invoices','invoice_reminders',
'time_entries','expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format('alter table %I add column created_by uuid references auth.users(id);', t);
execute format('alter table %I add column updated_by uuid references auth.users(id);', t);
execute format('alter table %I add column updated_at timestamptz not null default now();', t);
end loop;
end$$;
create or replace function set_audit_fields() returns trigger
language plpgsql as $$
begin
if tg_op = 'INSERT' then
new.created_by := coalesce(new.created_by, auth.uid());
new.updated_by := coalesce(new.updated_by, auth.uid());
new.updated_at := now();
elsif tg_op = 'UPDATE' then
new.created_by := old.created_by; -- nie ändern
new.updated_by := auth.uid();
new.updated_at := now();
end if;
return new;
end$$;
do $$
declare t text;
begin
for t in
select unnest(array[
'studios',
'persons','projects','quotes','invoices','invoice_reminders',
'time_entries','expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format(
'create trigger %I_audit before insert or update on %I
for each row execute function set_audit_fields();',
t, t
);
end loop;
end$$;
-- ════════════════════════════════════════════════════════════════════════════
-- ROW-LEVEL-SECURITY (RLS)
-- ============================================================================
-- Globale Policy: "User darf Zeile lesen/schreiben, wenn er Mitglied im
-- Studio (studio_id) der Zeile ist." Funktioniert für alle Tabellen mit
-- studio_id-Spalte. studios/studio_members/persons brauchen eigene Policies.
-- ════════════════════════════════════════════════════════════════════════════
-- Helper: prüft Mitgliedschaft des aktuellen Users in einem Studio
create or replace function is_studio_member(s_id uuid) returns boolean
language sql stable security definer as $$
select exists (
select 1 from studio_members
where studio_id = s_id
and user_id = auth.uid()
and active = true
);
$$;
-- Alle studio-bezogenen Tabellen
do $$
declare t text;
begin
for t in
select unnest(array[
'studio_settings','studio_roles','app_roles','dashboard_templates',
'absence_types','letter_templates','holidays',
'persons','projects','project_quote_links','quotes',
'invoices','invoice_reminders','time_entries',
'expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format('alter table %I enable row level security;', t);
end loop;
end$$;
-- Tabellen mit direkter studio_id-Spalte: einheitliche Policy
-- (persons ist hier ausgenommen, weil studio_id NULL sein darf — siehe unten)
do $$
declare t text;
begin
for t in
select unnest(array[
'studio_settings','studio_roles','app_roles','dashboard_templates',
'absence_types','letter_templates','holidays',
'projects','quotes',
'invoices','time_entries',
'expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','blog_posts'
])
loop
execute format($f$
create policy %I_member_access on %I
for all
using (is_studio_member(studio_id))
with check (is_studio_member(studio_id));
$f$, t, t);
end loop;
end$$;
-- persons: zwei Sichtbarkeitspfade (studio-lokal ODER via person_studio_links)
alter table person_studio_links enable row level security;
create policy persons_member_access on persons
for all
using (
(studio_id is not null and is_studio_member(studio_id))
or exists (
select 1 from person_studio_links psl
where psl.person_id = persons.id
and is_studio_member(psl.studio_id)
)
)
with check (
(studio_id is not null and is_studio_member(studio_id))
or exists (
select 1 from person_studio_links psl
where psl.person_id = persons.id
and is_studio_member(psl.studio_id)
)
);
create policy person_studio_links_member_access on person_studio_links
for all
using (is_studio_member(studio_id))
with check (is_studio_member(studio_id));
-- Sub-Tabellen ohne eigene studio_id: Zugriff via Parent
create policy project_quote_links_member_access on project_quote_links
for all
using (
exists (select 1 from projects p
where p.id = project_quote_links.project_id
and is_studio_member(p.studio_id))
);
create policy invoice_reminders_member_access on invoice_reminders
for all
using (
exists (select 1 from invoices i
where i.id = invoice_reminders.invoice_id
and is_studio_member(i.studio_id))
);
create policy delivery_note_items_member_access on delivery_note_items
for all
using (
exists (select 1 from delivery_notes dn
where dn.id = delivery_note_items.delivery_note_id
and is_studio_member(dn.studio_id))
);
-- studios: User sieht nur Studios, in denen er Mitglied ist
alter table studios enable row level security;
create policy studios_member_access on studios
for select
using (is_studio_member(id));
-- studio_members: User sieht eigene Mitgliedschaften
alter table studio_members enable row level security;
create policy studio_members_self_access on studio_members
for select
using (user_id = auth.uid() or is_studio_member(studio_id));
-- profiles: Jeder authentifizierte User sieht alle Profile (Anzeigenamen)
alter table profiles enable row level security;
create policy profiles_authenticated_read on profiles
for select
using (auth.role() = 'authenticated');
create policy profiles_self_write on profiles
for update using (id = auth.uid());
-- ════════════════════════════════════════════════════════════════════════════
-- Nächste Migrations:
-- 0002_storage.sql — Supabase Storage Buckets (receipts, logos) + Policies
-- 0003_seed_defaults.sql — Pro neues Studio die Default-Rollen, Absenz-Typen,
-- Letter-Templates, Dashboard-Templates anlegen
-- ════════════════════════════════════════════════════════════════════════════
+84
View File
@@ -0,0 +1,84 @@
-- ============================================================================
-- RAPPORT — Storage Buckets (Supabase Storage / S3)
-- ============================================================================
-- Zweck: Datei-Uploads (Quittungen, Studio-Logos) liegen NICHT als Base64 in
-- der DB, sondern in Supabase Storage. Die DB hält nur den Pfad
-- (z.B. expenses.receipt_url = 'receipts/<studio_id>/2025/abc.pdf').
--
-- Konvention für Pfade: '<studio_id>/<jahr>/<datei>.<ext>'
-- → erste Path-Komponente = studio_id (für RLS)
--
-- Buckets sind PRIVATE — Zugriff nur über signierte URLs (zeitlich begrenzt).
-- ============================================================================
-- Hinweis: KEINE `public`-Spalte angeben. Beim Postgres-Init existiert sie in
-- storage.buckets noch nicht (die fügt die Storage-API erst beim Boot per
-- eigener Migration hinzu). Default ist `false` → Buckets sind privat, wie
-- gewünscht. Würden wir `public` referenzieren, bräche der Init hier ab und
-- ALLE folgenden Migrations (inkl. ensure_profile in 0005) liefen nicht mehr.
insert into storage.buckets (id, name)
values
('receipts', 'receipts'),
('logos', 'logos')
on conflict (id) do nothing;
-- ────────────────────────────────────────────────────────────────────────────
-- RLS-Policies auf storage.objects
-- ────────────────────────────────────────────────────────────────────────────
-- Prinzip: erste Pfad-Komponente ist studio_id; Zugriff nur wenn Member.
-- `split_part(name, '/', 1)` gibt die erste Pfad-Komponente zurück.
--
-- Bewusst NICHT storage.foldername() benutzen: die Storage-API droppt/erstellt
-- diese Funktion bei ihren eigenen Boot-Migrations neu. Eine Policy-Abhängigkeit
-- darauf würde diesen Drop blockieren ("cannot drop function foldername") und
-- die Storage-API in eine Crash-Loop schicken. split_part ist ein eingebautes
-- Postgres-Builtin ohne diese Kopplung.
create policy "rapport_storage_read"
on storage.objects for select
using (
bucket_id in ('receipts','logos')
and is_studio_member( split_part(name, '/', 1)::uuid )
);
create policy "rapport_storage_insert"
on storage.objects for insert
with check (
bucket_id in ('receipts','logos')
and is_studio_member( split_part(name, '/', 1)::uuid )
);
create policy "rapport_storage_update"
on storage.objects for update
using (
bucket_id in ('receipts','logos')
and is_studio_member( split_part(name, '/', 1)::uuid )
);
create policy "rapport_storage_delete"
on storage.objects for delete
using (
bucket_id in ('receipts','logos')
and is_studio_member( split_part(name, '/', 1)::uuid )
);
-- ────────────────────────────────────────────────────────────────────────────
-- Hinweise für den Adapter (kein SQL, nur Doku):
-- ────────────────────────────────────────────────────────────────────────────
-- Upload (Frontend, in SupabaseAdapter):
-- const path = `${studioId}/${year}/${uuid()}.${ext}`
-- await supabase.storage.from('receipts').upload(path, file)
-- // Pfad in expenses.receipt_url speichern
--
-- Anzeige:
-- const { data } = await supabase.storage
-- .from('receipts')
-- .createSignedUrl(receipt_url, 60) // 60 Sekunden gültig
-- // <img src={data.signedUrl} />
--
-- Migration localStorage → Cloud (im Push-Wizard):
-- for jede expense mit receiptData (Base64):
-- blob = base64ToBlob(receiptData)
-- path = upload(blob)
-- row.receipt_url = path; delete row.receiptData
-- ============================================================================
+116
View File
@@ -0,0 +1,116 @@
-- ============================================================================
-- RAPPORT — Default-Stammdaten pro neuem Studio
-- ============================================================================
-- Wenn ein neues Studio angelegt wird (INSERT INTO studios), bekommt es
-- automatisch:
-- - eine studio_settings-Zeile (alle Defaults aus CREATE TABLE)
-- - 4 studio_roles (PL, TS, BL, AS)
-- - 3 dashboard_templates (admin, projektleiter, mitarbeiter)
-- - 3 app_roles (r-admin, r-projektleiter, r-mitarbeiter)
-- - 7 absence_types (Krankheit, Unfall, …)
-- - 2 letter_templates (Offerte, Zahlungserinnerung)
--
-- Quelle der Werte: src/constants.js (`defaultData`) + DEFAULT_ABSENZ_TYPES.
-- Wenn dort etwas geändert wird, hier nachziehen — und umgekehrt.
--
-- SECURITY DEFINER: Funktion läuft mit Postgres-Owner-Rechten, umgeht damit
-- die RLS-Policies (der gerade anlegende User ist noch nicht studio_member,
-- könnte sonst nichts einfügen).
-- ============================================================================
create or replace function seed_studio_defaults(s_id uuid)
returns void
language plpgsql
security definer
as $$
begin
-- 1. studio_settings (1 Zeile, alle Defaults aus CREATE TABLE)
insert into studio_settings (studio_id) values (s_id);
-- 2. studio_roles (Rate-Profile)
insert into studio_roles (studio_id, id, label, rate, sort) values
(s_id, 'PL', 'Projektleiter/in', 140, 1),
(s_id, 'TS', 'Technischer Support', 120, 2),
(s_id, 'BL', 'Bauleiter/in', 135, 3),
(s_id, 'AS', 'Administrativer Support', 120, 4);
-- 3. dashboard_templates (vor app_roles wegen FK)
insert into dashboard_templates (studio_id, id, name, is_public, layout) values
(s_id, 'tpl-admin', 'Administrator', true, $j$[
{"id":"dw-a1","cols":4,"minH":0,"widgets":["kpi-projekte","kpi-stunden","kpi-ausstehend","kpi-umsatz"]},
{"id":"dw-a2","cols":1,"minH":0,"widgets":["warnungen"]},
{"id":"dw-a3","cols":2,"minH":0,"widgets":["aktive-projekte","unverrechnete-stunden"]},
{"id":"dw-a4","cols":2,"minH":0,"widgets":["umsatz-sparkline","offene-offerten"]},
{"id":"dw-a5","cols":1,"minH":0,"widgets":["letzte-zeiteintraege"]}
]$j$::jsonb),
(s_id, 'tpl-projektleiter', 'Projektleiter', true, $j$[
{"id":"dw-p1","cols":2,"minH":0,"widgets":["kpi-projekte","kpi-stunden"]},
{"id":"dw-p2","cols":1,"minH":0,"widgets":["warnungen"]},
{"id":"dw-p3","cols":3,"minH":0,"widgets":["meine-projekte","team-auslastung","offene-offerten"]},
{"id":"dw-p4","cols":1,"minH":0,"widgets":["letzte-zeiteintraege"]}
]$j$::jsonb),
(s_id, 'tpl-mitarbeiter', 'Mitarbeiter', true, $j$[
{"id":"dw-m1","cols":3,"minH":0,"widgets":["kpi-stunden","ueberstunden","meine-ferien"]},
{"id":"dw-m2","cols":2,"minH":0,"widgets":["meine-projekte","stunden-woche"]},
{"id":"dw-m3","cols":1,"minH":0,"widgets":["meine-zeiteintraege"]}
]$j$::jsonb);
-- 4. app_roles (permissions=NULL bedeutet "alle erlaubt")
insert into app_roles (studio_id, id, name, permissions, dashboard_template_id) values
(s_id, 'r-admin', 'Administrator',
null,
'tpl-admin'),
(s_id, 'r-projektleiter', 'Projektleiter',
array['dashboard','projects','time','quotes','personen','mitarbeiter','settings'],
'tpl-projektleiter'),
(s_id, 'r-mitarbeiter', 'Mitarbeiter',
array['dashboard','projects','time','personen','settings'],
'tpl-mitarbeiter');
-- 5. absence_types (aus DEFAULT_ABSENZ_TYPES in constants.js)
insert into absence_types (studio_id, id, label, color) values
(s_id, 'krankheit', 'Krankheit', '#8a1a1a'),
(s_id, 'unfall', 'Unfall', '#b5621e'),
(s_id, 'intern', 'Intern', '#1a4e8a'),
(s_id, 'informatik', 'Informatik', '#555'),
(s_id, 'rechnungswesen', 'Rechnungswesen', '#7a6a00'),
(s_id, 'weiterbildung', 'Weiterbildung', '#2d6a4f'),
(s_id, 'militaer', 'Militär / Zivildienst', '#3d3d38');
-- 6. letter_templates
insert into letter_templates (studio_id, id, name, body) values
(s_id, 'offer', 'Offerte',
$b$Sehr geehrte/r {{client}}
Gerne unterbreiten wir Ihnen die Offerte für das Projekt «{{project}}».
[Leistungsumfang]
Honorar: CHF [Betrag]
Wir freuen uns auf die Zusammenarbeit.
Freundliche Grüsse$b$),
(s_id, 'reminder', 'Zahlungserinnerung',
$b$Sehr geehrte/r {{client}}
Bei einer Überprüfung unserer Buchhaltung stellen wir fest, dass die Rechnung [Nr.] vom [Datum] über CHF [Betrag] noch nicht beglichen ist.
Wir bitten Sie höflich, den offenen Betrag innert 10 Tagen zu überweisen.
Freundliche Grüsse$b$);
end$$;
-- ─── Trigger: bei jedem Studio-Insert die Defaults reinkippen ──────────────
create or replace function trg_studios_seed_defaults()
returns trigger
language plpgsql
as $$
begin
perform seed_studio_defaults(new.id);
return new;
end$$;
create trigger studios_seed_defaults
after insert on studios
for each row execute function trg_studios_seed_defaults();
+39
View File
@@ -0,0 +1,39 @@
-- ============================================================================
-- RAPPORT — Realtime-Subscriptions aktivieren
-- ============================================================================
-- Selfhosted-Supabase hat die `supabase_realtime` Publication standardmäßig
-- leer. Damit das Frontend Live-Updates bekommt (User A ändert → User B sieht
-- es ohne Reload), müssen die zu beobachtenden Tabellen explizit der
-- Publication beitreten.
--
-- Tenant-Layer (studios, studio_members) bewusst ausgenommen: ändert sich
-- selten und braucht keine Live-Sync zwischen Clients.
-- ============================================================================
alter publication supabase_realtime add table
studio_settings,
studio_roles,
app_roles,
dashboard_templates,
absence_types,
letter_templates,
holidays,
persons,
person_studio_links,
projects,
project_quote_links,
quotes,
invoices,
invoice_reminders,
time_entries,
expenses,
internal_expenses,
employees,
absences,
vacation_entries,
payroll_entries,
overtime_closings,
protocols,
delivery_notes,
delivery_note_items,
blog_posts;
@@ -0,0 +1,65 @@
-- ============================================================================
-- RAPPORT — RPC-Funktionen für Sign-Up / Studio-Anlage
-- ============================================================================
-- Zwei SECURITY-DEFINER-Funktionen, die der signUp-Flow im Frontend braucht:
--
-- 1. `ensure_profile(username, display_name)` — legt für den eingeloggten User
-- eine profiles-Zeile an (oder aktualisiert sie). Würde sonst an fehlender
-- INSERT-Policy scheitern.
--
-- 2. `create_studio_with_admin(name, slug)` — legt atomar Studio + Membership
-- als Admin für den eingeloggten User an. Seed-Trigger füllt die Defaults.
-- Liefert die studio_id zurück.
--
-- Beide laufen mit Postgres-Owner-Rechten und sind explizit von einem
-- authentifizierten User aufrufbar (Check via auth.uid()).
-- ============================================================================
create or replace function ensure_profile(p_username text, p_display_name text)
returns uuid
language plpgsql
security definer
as $$
declare
v_user_id uuid := auth.uid();
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
insert into profiles (id, username, display_name)
values (v_user_id, p_username, p_display_name)
on conflict (id) do update set
username = excluded.username,
display_name = excluded.display_name;
return v_user_id;
end;
$$;
create or replace function create_studio_with_admin(p_name text, p_slug text)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
-- seed_studio_defaults-Trigger läuft hier automatisch und füllt Stammdaten
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
return v_studio_id;
end;
$$;
-- Sichtbarkeit: authentifizierte User dürfen diese Funktionen aufrufen.
-- (`security definer` reicht — der Owner ist `postgres`, der hat überall Rechte.)
grant execute on function ensure_profile(text, text) to authenticated;
grant execute on function create_studio_with_admin(text, text) to authenticated;
+28
View File
@@ -0,0 +1,28 @@
-- ============================================================================
-- RAPPORT — Public Studio-Liste für Login-Dropdown
-- ============================================================================
-- Wenn auf einer Supabase-Instanz mehrere Firmen / Studios gehostet sind,
-- soll der Login-Screen vor Email+Passwort einen Dropdown zeigen: «In welches
-- Studio möchten Sie sich einloggen?». Dafür braucht das Frontend eine Liste
-- aller Studios — ohne dass jemand bereits eingeloggt sein muss.
--
-- RLS verhindert das normalerweise (`studios_member_access` nur für Member).
-- Diese SECURITY-DEFINER-Funktion umgeht RLS und liefert nur die öffentlichen
-- Identitäts-Felder (name, slug). Keine Tenant-Daten, kein Risiko.
--
-- Trade-Off: Studio-Namen sind in dem Sinne «öffentlich» — wer die Server-URL
-- kennt, kann sehen welche Firmen hier hosten. Bei einem Selfhosted-Setup für
-- 1-3 befreundete Studios ist das akzeptabel; bei einer Public-SaaS-Instanz
-- wäre das ein Re-Design wert.
-- ============================================================================
create or replace function list_studios()
returns table(id uuid, name text, slug text)
language sql
security definer
stable
as $$
select id, name, slug from studios order by name;
$$;
grant execute on function list_studios() to anon, authenticated;
@@ -0,0 +1,83 @@
-- ============================================================================
-- RAPPORT — Personen-Sharing beim Studio-Anlegen
-- ============================================================================
-- Erweitert `create_studio_with_admin` um einen optionalen dritten Parameter:
-- eine Liste von Quell-Studio-IDs, deren Personen ins neue Studio übernommen
-- werden sollen.
--
-- Mechanik (siehe 0001 — Persons mit nullable studio_id + person_studio_links):
-- 1. Lokale Personen aus Quell-Studio werden globalisiert (studio_id = NULL)
-- und bekommen einen Link an das Quell-Studio (Sichtbarkeit bleibt erhalten).
-- 2. Alle Personen, die im Quell-Studio sichtbar sind, bekommen zusätzlich
-- einen Link an das neue Studio.
--
-- Security: User muss in allen Quell-Studios Member sein, sonst Exception.
-- ============================================================================
drop function if exists create_studio_with_admin(text, text);
create function create_studio_with_admin(
p_name text,
p_slug text,
p_share_persons_from uuid[] default '{}'
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
v_source_id uuid;
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
-- Sicherheits-Check: User muss in allen Quell-Studios aktiver Member sein
if array_length(p_share_persons_from, 1) > 0 then
if exists (
select 1 from unnest(p_share_persons_from) src
where not exists (
select 1 from studio_members sm
where sm.user_id = v_user_id
and sm.studio_id = src
and sm.active = true
)
) then
raise exception 'You are not a member of all source studios';
end if;
end if;
-- Studio + Admin-Membership anlegen (seed_studio_defaults-Trigger feuert)
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
-- Personen-Sharing pro Quell-Studio
if array_length(p_share_persons_from, 1) > 0 then
foreach v_source_id in array p_share_persons_from loop
-- Schritt 1: Lokale Personen des Quell-Studios globalisieren.
-- a) Link an Quell-Studio anlegen (primary_studio = true, weil sie dort ursprünglich entstanden)
insert into person_studio_links (person_id, studio_id, primary_studio)
select id, v_source_id, true
from persons
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
-- b) Personen globalisieren (studio_id auf NULL)
update persons set studio_id = NULL where studio_id = v_source_id;
-- Schritt 2: alle im Quell-Studio sichtbaren Personen auch dem neuen Studio zuordnen
insert into person_studio_links (person_id, studio_id)
select person_id, v_studio_id
from person_studio_links
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
end loop;
end if;
return v_studio_id;
end;
$$;
grant execute on function create_studio_with_admin(text, text, uuid[]) to authenticated;
+27
View File
@@ -0,0 +1,27 @@
-- ============================================================================
-- RAPPORT — Personen-Load mit Sharing-Support
-- ============================================================================
-- Direkter `select * from persons where studio_id = $X` sieht nur lokale
-- Personen. Geteilte Personen haben studio_id = NULL und ihre Sichtbarkeit
-- kommt aus `person_studio_links`. Diese Funktion vereint beide Quellen.
--
-- Kein SECURITY DEFINER — RLS bleibt aktiv, der User sieht nur, was er sehen
-- darf. Die Funktion ist ein bequemer Query-Wrapper, kein Privilege-Escalator.
-- ============================================================================
create or replace function load_persons_for_studio(p_studio_id uuid)
returns setof persons
language sql
stable
as $$
select p.* from persons p
where p.studio_id = p_studio_id
or exists (
select 1 from person_studio_links psl
where psl.person_id = p.id
and psl.studio_id = p_studio_id
)
order by p.name;
$$;
grant execute on function load_persons_for_studio(uuid) to authenticated;
@@ -0,0 +1,61 @@
-- ============================================================================
-- RAPPORT — Mitarbeiter einladen (Admin-Aktion)
-- ============================================================================
-- Two-Step-Flow (vom Frontend orchestriert):
-- 1. Admin ruft `supabase.auth.signUp(email, tempPassword)` mit einem
-- temporären Client (ohne Session-persist), damit Admin-Session nicht
-- "übernommen" wird. → liefert neue user_id.
-- 2. Admin ruft `attach_user_to_studio(user_id, studio_id, role, username, name)`
-- mit seinem eigenen Auth-Token. RPC prüft, dass Caller Admin im
-- Ziel-Studio ist, und legt Profil + Membership an.
--
-- Sicherheit: nur Admins eines Studios können dort Mitglieder hinzufügen.
-- `attach` ist idempotent (ON CONFLICT update), damit der Flow re-runnable ist.
-- ============================================================================
create or replace function attach_user_to_studio(
p_user_id uuid,
p_studio_id uuid,
p_app_role_id text,
p_username text,
p_display_name text
)
returns void
language plpgsql
security definer
as $$
declare
v_caller_id uuid := auth.uid();
begin
if v_caller_id is null then
raise exception 'Authentication required';
end if;
-- Caller muss Admin in Ziel-Studio sein
if not exists (
select 1 from studio_members
where user_id = v_caller_id
and studio_id = p_studio_id
and app_role_id = 'r-admin'
and active = true
) then
raise exception 'Only admins of the studio can invite members';
end if;
-- Profile
insert into profiles (id, username, display_name)
values (p_user_id, p_username, p_display_name)
on conflict (id) do update set
username = excluded.username,
display_name = excluded.display_name;
-- Membership (idempotent)
insert into studio_members (studio_id, user_id, app_role_id)
values (p_studio_id, p_user_id, p_app_role_id)
on conflict (studio_id, user_id) do update set
app_role_id = excluded.app_role_id,
active = true;
end;
$$;
grant execute on function attach_user_to_studio(uuid, uuid, text, text, text) to authenticated;
@@ -0,0 +1,79 @@
-- ============================================================================
-- RAPPORT — Studio-Name aus Init in studio_settings übernehmen
-- ============================================================================
-- Bisher hat `create_studio_with_admin` nur `studios.name` und `studios.slug`
-- gesetzt. `studio_settings.name` wurde vom Seed-Trigger als Default
-- ("Mein Studio") angelegt — was im Frontend dann als Studio-Header und
-- Sidebar-Label erscheint. Discrepanz zur User-Eingabe.
--
-- Fix: nach Seed-Trigger den Studio-Namen in `studio_settings` schreiben und
-- `setup_completed = true` setzen (Frontend nutzt das für Setup-Wizard-Check).
-- ============================================================================
drop function if exists create_studio_with_admin(text, text, uuid[]);
create function create_studio_with_admin(
p_name text,
p_slug text,
p_share_persons_from uuid[] default '{}'
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
v_source_id uuid;
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
if array_length(p_share_persons_from, 1) > 0 then
if exists (
select 1 from unnest(p_share_persons_from) src
where not exists (
select 1 from studio_members sm
where sm.user_id = v_user_id
and sm.studio_id = src
and sm.active = true
)
) then
raise exception 'You are not a member of all source studios';
end if;
end if;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
-- NEU: Studio-Name + setup_completed in die settings übernehmen, damit
-- das Frontend nicht "Mein Studio" anzeigt und der Setup-Wizard nicht
-- erneut triggert.
update studio_settings
set name = p_name, setup_completed = true
where studio_id = v_studio_id;
if array_length(p_share_persons_from, 1) > 0 then
foreach v_source_id in array p_share_persons_from loop
insert into person_studio_links (person_id, studio_id, primary_studio)
select id, v_source_id, true
from persons
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
update persons set studio_id = NULL where studio_id = v_source_id;
insert into person_studio_links (person_id, studio_id)
select person_id, v_studio_id
from person_studio_links
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
end loop;
end if;
return v_studio_id;
end;
$$;
grant execute on function create_studio_with_admin(text, text, uuid[]) to authenticated;
@@ -0,0 +1,65 @@
-- ============================================================================
-- RAPPORT — Studio für einen bestimmten User anlegen (Server-/Hosting-Pfad)
-- ============================================================================
-- `create_studio_with_admin` nutzt auth.uid() und läuft nur im Kontext eines
-- eingeloggten Users (Frontend). RAPPORT-HOST provisioniert aber serverseitig
-- mit service_role und kennt keinen auth.uid() — es übergibt die Ziel-User-ID
-- explizit.
--
-- `create_studio_for_user` ist die service_role-Variante: identische Wirkung
-- (Studio + Admin-Membership + Settings), aber die User-ID ist ein Parameter.
-- Bewusst NICHT an `authenticated` gegrantet — nur service_role darf das, sonst
-- könnte ein User sich selbst zum Admin beliebiger Studios machen.
-- ============================================================================
create or replace function create_studio_for_user(
p_user_id uuid,
p_name text,
p_slug text,
p_username text default null,
p_display_name text default null
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_email text;
v_username text;
v_display text;
begin
if p_user_id is null then
raise exception 'p_user_id required';
end if;
select email into v_email from auth.users where id = p_user_id;
if v_email is null then
raise exception 'user % does not exist', p_user_id;
end if;
-- Profil sicherstellen (profiles.username/display_name sind NOT NULL; das
-- Frontend braucht sie beim ersten Login in die Instanz). Aus E-Mail
-- abgeleitet, falls nicht explizit übergeben.
v_username := coalesce(nullif(p_username, ''), split_part(v_email, '@', 1));
v_display := coalesce(nullif(p_display_name, ''), v_username);
insert into profiles (id, username, display_name)
values (p_user_id, v_username, v_display)
on conflict (id) do nothing;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, p_user_id, 'r-admin');
-- Studio-Name + setup_completed in die settings übernehmen (Seed-Trigger hat
-- die Zeile mit Defaults bereits angelegt) — analog create_studio_with_admin.
update studio_settings
set name = p_name, setup_completed = true
where studio_id = v_studio_id;
return v_studio_id;
end;
$$;
-- Nur service_role (RAPPORT-HOST). KEIN Grant an anon/authenticated.
revoke all on function create_studio_for_user(uuid, text, text, text, text) from public;
grant execute on function create_studio_for_user(uuid, text, text, text, text) to service_role;