27b1057cd4
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>
762 lines
37 KiB
PL/PgSQL
762 lines
37 KiB
PL/PgSQL
-- ============================================================================
|
||
-- 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
|
||
-- ════════════════════════════════════════════════════════════════════════════
|