Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88825ebe0 | |||
| afc6163b2d | |||
| 44ddc5ee12 | |||
| 266e7d61d4 | |||
| 5a34d0a60f | |||
| 1846a00d07 | |||
| 4c04f1cb56 | |||
| df69a2dc6b | |||
| bb69cc0657 | |||
| edee7b9f28 | |||
| 2bd516a9ab | |||
| 40a28d5ff5 | |||
| 0257f98524 | |||
| 27b1057cd4 | |||
| c71feddf63 | |||
| 0fc4dd0e08 |
@@ -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=
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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 30000–31999
|
||||||
|
- `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` (5–10 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+97
-5
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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,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
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"); }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 31–33" /></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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -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\"}"
|
||||||
@@ -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
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -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
|
||||||
|
-- ============================================================================
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user