Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)
Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server. Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync. Storage-Architektur - src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter - src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends - Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus Postgres-Schema (supabase/migrations/0001–0010) - 29 Tabellen, multi-tenant via studio_id + Row-Level-Security - Audit-Spalten (created_by/updated_by/at) + Trigger - Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen) - Realtime-Publication für Live-Sync - RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing), list_studios, load_persons_for_studio, attach_user_to_studio Cloud-Features (App) - BackendChoice.jsx als Erst-Screen «Lokal oder Cloud» - CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung - Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen - ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event - Realtime: Änderungen zwischen Browsern ohne Reload sichtbar - Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen - Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion) - Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen - Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig Web-Deploy - deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080 - .env.production.example: Build-time Cloud-URL - DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager Doku - README.md: Cloud-Variante prominent erklärt - ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle - DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml. Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
Reference in New Issue
Block a user