Files
RAPPORT/src/storage/supabase-mappers.js
T
karim 27b1057cd4 Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)
Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server.
Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:08:00 +02:00

650 lines
18 KiB
JavaScript

// 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,
}),
};