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:
+276
-99
@@ -1,8 +1,13 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react";
|
||||
import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js";
|
||||
import { migrateDashboardLayout, verifyPassword, withHashedPassword, stripCredentials } from "./utils.js";
|
||||
import { NAV_ITEMS, defaultData } from "./constants.js";
|
||||
import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js";
|
||||
import { storage, isCloudBackend } from "./storage/adapter.js";
|
||||
import { applyMigrations } from "./storage/migrations.js";
|
||||
import Login from "./views/Login.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 UpdateNotifier from "./components/UpdateNotifier.jsx";
|
||||
|
||||
@@ -46,88 +51,63 @@ function ViewFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [data, setData] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
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 };
|
||||
// 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);
|
||||
// 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 {
|
||||
const parsed = await storage.load();
|
||||
if (!cancelled && parsed) {
|
||||
setData(applyMigrations(parsed, defaultData));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Cloud-Resume load failed:", e);
|
||||
}
|
||||
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 };
|
||||
// Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt
|
||||
try {
|
||||
const list = await storage.listStudios?.();
|
||||
if (!cancelled) setCloudStudios(list || []);
|
||||
} catch (e) {
|
||||
console.error("listStudios failed:", e);
|
||||
if (!cancelled) setCloudStudios([]);
|
||||
}
|
||||
if (!cancelled) setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
return defaultData;
|
||||
});
|
||||
const [isNewInstall] = useState(() => !localStorage.getItem(STORAGE_KEY));
|
||||
const hasData = await storage.hasExistingData();
|
||||
if (cancelled) return;
|
||||
setIsNewInstall(!hasData);
|
||||
const parsed = await storage.load();
|
||||
if (cancelled) return;
|
||||
setData(parsed ? applyMigrations(parsed, defaultData) : defaultData);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
const [currentUser, setCurrentUser] = useState(() => {
|
||||
try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; }
|
||||
});
|
||||
@@ -137,11 +117,11 @@ export default function App() {
|
||||
setCurrentUser(safe);
|
||||
};
|
||||
// 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.
|
||||
// Legacy plaintext passwords are accepted ONCE and transparently upgraded to
|
||||
// PBKDF2 hashes on first successful login.
|
||||
const verifyLogin = async (username, password) => {
|
||||
const u = (data.users || []).find(x => x.username === username);
|
||||
// Routes by backend: cloud → Supabase Auth, local → PBKDF2 gegen data.users.
|
||||
const verifyLogin = async (usernameOrEmail, password, opts = {}) => {
|
||||
if (isCloudBackend) return cloudSignIn(usernameOrEmail, password, opts.studioId);
|
||||
|
||||
const u = (data.users || []).find(x => x.username === usernameOrEmail);
|
||||
if (!u) return null;
|
||||
const ok = await verifyPassword(password, u);
|
||||
if (!ok) return null;
|
||||
@@ -153,14 +133,100 @@ export default function App() {
|
||||
return upgraded;
|
||||
} catch (e) {
|
||||
console.error("Passwort-Migration fehlgeschlagen:", e);
|
||||
// fall through — still let the user in with legacy plaintext
|
||||
}
|
||||
}
|
||||
handleLogin(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 = () => {
|
||||
sessionStorage.removeItem("rapport_user");
|
||||
sessionStorage.removeItem("rapport_studio_id");
|
||||
if (isCloudBackend) {
|
||||
storage.signOut?.().catch(() => {});
|
||||
}
|
||||
setCurrentUser(null);
|
||||
};
|
||||
const handleSetupComplete = (newData) => {
|
||||
@@ -231,8 +297,8 @@ export default function App() {
|
||||
const [modal, setModal] = useState(null);
|
||||
const [printContent, setPrintContent] = useState(null);
|
||||
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
|
||||
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.7");
|
||||
const [changelogVersion, setChangelogVersion] = useState("0.7");
|
||||
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8");
|
||||
const [changelogVersion, setChangelogVersion] = useState("0.8");
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
|
||||
@@ -304,16 +370,75 @@ export default function App() {
|
||||
|
||||
const save = useCallback((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) => {
|
||||
save({ ...data, [key]: value });
|
||||
}, [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).
|
||||
const lastOverdueCheck = useRef(null);
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (lastOverdueCheck.current === today) return;
|
||||
lastOverdueCheck.current = today;
|
||||
@@ -323,18 +448,57 @@ export default function App() {
|
||||
);
|
||||
if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
|
||||
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)
|
||||
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.
|
||||
const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1";
|
||||
if (!hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) {
|
||||
return <BackendChoice />;
|
||||
}
|
||||
|
||||
// Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus
|
||||
// erfolgt Erst-Einrichtung über den Init-Dialog im Login.
|
||||
if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) {
|
||||
return <Setup onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
if (!localStorage.getItem("rapport_v0_5_migrated")) {
|
||||
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
|
||||
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!currentUser) {
|
||||
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.7" />;
|
||||
if (isCloudBackend && cloudStudios !== null && cloudStudios.length === 0) {
|
||||
const cloudUrl = localStorage.getItem("rapport_cloud_url") || "";
|
||||
return <CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />;
|
||||
}
|
||||
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.8" />;
|
||||
}
|
||||
|
||||
if (printContent) {
|
||||
@@ -607,8 +771,8 @@ export default function App() {
|
||||
<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" }}
|
||||
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" }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.7</button>
|
||||
<button onClick={() => { setChangelogVersion("0.8"); 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.8</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -652,7 +816,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 && <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 === "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 === "lieferscheine" && <DeliveryNotes data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||||
{view === "protokolle" && <Protocols data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||||
@@ -673,6 +837,19 @@ export default function App() {
|
||||
|
||||
{showChangelog && (() => {
|
||||
const CHANGELOGS = {
|
||||
"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": {
|
||||
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."],
|
||||
@@ -737,7 +914,7 @@ export default function App() {
|
||||
},
|
||||
};
|
||||
const versions = Object.keys(CHANGELOGS);
|
||||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.7"];
|
||||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8"];
|
||||
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={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
|
||||
@@ -766,7 +943,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
<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"); }}>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
@@ -781,7 +958,7 @@ export default function App() {
|
||||
<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={{ 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 · Studio-Management für Architekturbüros</div>
|
||||
</div>
|
||||
<div style={{ padding: "20px 32px 8px" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter() {
|
||||
// Build-time-Default für Web-Deploy: wenn eine Production-Build VITE_SUPABASE_URL
|
||||
// gesetzt hat (z.B. via .env.production) und der User noch nichts gewählt hat,
|
||||
// wird Cloud automatisch der Modus. Damit landet ein Browser, der app.rapport.kgva.ch
|
||||
// öffnet, direkt im Cloud-Login (bzw. Init-Dialog wenn Server leer).
|
||||
// Dev (`npm run dev`) ist ausgenommen — dort sieht der User weiterhin den
|
||||
// BackendChoice-Screen, weil .env.local oft auf localhost zeigt.
|
||||
if (typeof localStorage !== "undefined" && import.meta.env.PROD) {
|
||||
const envUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
if (envUrl && !localStorage.getItem("rapport_backend_chosen")) {
|
||||
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") {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
if (!url || !key) {
|
||||
console.warn("rapport_backend=cloud, aber VITE_SUPABASE_URL/ANON_KEY fehlen — Fallback auf LocalStorage.");
|
||||
return new LocalStorageAdapter();
|
||||
}
|
||||
console.info("Storage-Adapter: SupabaseAdapter aktiv (URL:", url + ")");
|
||||
return new SupabaseAdapter(url, key);
|
||||
}
|
||||
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,484 @@
|
||||
// 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");
|
||||
if (error) {
|
||||
console.error("listStudios:", error.message);
|
||||
return [];
|
||||
}
|
||||
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,113 @@
|
||||
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)
|
||||
|
||||
const envCloudUrl = 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+231
-11
@@ -1,4 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { storage, isCloudBackend } from "../storage/adapter.js";
|
||||
|
||||
const isValidEmail = (s) => /.+@.+\..+/.test(s);
|
||||
|
||||
// Simple per-tab rate limit: after MAX_ATTEMPTS failed tries, lock for LOCK_MS.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
@@ -14,14 +17,46 @@ function writeAttempts(state) {
|
||||
}
|
||||
|
||||
export default function Login({ verifyLogin, settings, version }) {
|
||||
// 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 [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [shake, setShake] = useState(false);
|
||||
const [lockedUntil, setLockedUntil] = useState(() => readAttempts().lockedUntil);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!lockedUntil || lockedUntil <= now) return;
|
||||
@@ -32,12 +67,34 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
const isLocked = lockedUntil > now;
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (isLocked || submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
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) {
|
||||
writeAttempts({ count: 0, lockedUntil: 0 });
|
||||
} else {
|
||||
@@ -50,6 +107,7 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
setLockedUntil(next.lockedUntil);
|
||||
setNow(Date.now());
|
||||
setError(true);
|
||||
setErrorMsg(isCloud ? "Anmeldung fehlgeschlagen." : "Falscher Benutzername oder Passwort");
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 500);
|
||||
}
|
||||
@@ -58,7 +116,20 @@ 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";
|
||||
|
||||
const showUrlField = isCloud && editUrl;
|
||||
const showUrlBadge = isCloud && !editUrl && cloudUrl;
|
||||
|
||||
// Hostname zur Anzeige (ohne Protokoll, ohne Port falls Standard)
|
||||
let urlDisplay = cloudUrl;
|
||||
try {
|
||||
const u = new URL(cloudUrl);
|
||||
urlDisplay = u.host;
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -120,12 +191,36 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
background: #c4bbb0;
|
||||
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>
|
||||
|
||||
<div className={`login-card${shake ? " shake" : ""}`} style={{
|
||||
background: "#fdfcfa",
|
||||
borderRadius: 20,
|
||||
padding: "48px 44px 40px",
|
||||
padding: "48px 44px 28px",
|
||||
width: "100%", maxWidth: 380,
|
||||
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
|
||||
border: "1px solid #ddd8d0",
|
||||
@@ -136,29 +231,49 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
RAPPORT
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>
|
||||
{studioName.toUpperCase()}
|
||||
{studioHeader.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{isCloud && studios.length > 1 && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
||||
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 }}>
|
||||
BENUTZER
|
||||
{userLabel}
|
||||
</label>
|
||||
<input
|
||||
className={`login-input${error ? " error" : ""}`}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
type={userInputType}
|
||||
autoComplete={isCloud ? "email" : "username"}
|
||||
autoFocus
|
||||
disabled={isLocked}
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); setError(false); }}
|
||||
placeholder="admin"
|
||||
placeholder={userPlaceholder}
|
||||
/>
|
||||
</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 }}>
|
||||
PASSWORT
|
||||
</label>
|
||||
@@ -173,13 +288,29 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
/>
|
||||
</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 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -188,7 +319,96 @@ export default function Login({ verifyLogin, settings, version }) {
|
||||
</button>
|
||||
</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) */}
|
||||
<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}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+8
-13
@@ -1,10 +1,10 @@
|
||||
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 { Header, Modal, FormField, StatusBadge, StatusSelect, useConfirm , DateInput } from "../components/UI.jsx";
|
||||
|
||||
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 roles = data.settings.roles || [];
|
||||
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(),
|
||||
};
|
||||
|
||||
// Beide Updates atomar
|
||||
setData(prev => {
|
||||
const updatedInvoices = [...prev.invoices, newInv];
|
||||
const updatedQuotes = (prev.quotes || []).map(x => x.id === q.id ? {
|
||||
...x, status: mode === "schluss" ? "angenommen" : x.status,
|
||||
} : x);
|
||||
const next = { ...prev, invoices: updatedInvoices, quotes: updatedQuotes };
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Beide Updates atomar via saveAll (geht durch den Storage-Adapter)
|
||||
const updatedInvoices = [...data.invoices, newInv];
|
||||
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? {
|
||||
...x, status: mode === "schluss" ? "angenommen" : x.status,
|
||||
} : x);
|
||||
saveAll({ ...data, invoices: updatedInvoices, quotes: updatedQuotes });
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+240
-12
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { STORAGE_KEY, DEFAULT_ABSENZ_TYPES } from "../constants.js";
|
||||
import React, { useState, useEffect } from "react";
|
||||
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 { Header, FormField, Modal, DateInput, useConfirm } from "../components/UI.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 [absenzTypeForm, setAbsenzTypeForm] = useState({ label: "", color: "#555" });
|
||||
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 isDirty = JSON.stringify(s) !== JSON.stringify(data.settings);
|
||||
const isAdmin = !currentUser || currentUser.role === "admin";
|
||||
@@ -253,7 +265,7 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
|
||||
try {
|
||||
const imported = JSON.parse(evt.target.result);
|
||||
if (await askConfirm("Aktuelle Daten wirklich überschreiben?", "Überschreiben")) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(imported));
|
||||
await storage.save(imported);
|
||||
window.location.reload();
|
||||
}
|
||||
} 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 () => {
|
||||
setInitModal(false);
|
||||
if (!await askConfirm("Wirklich initialisieren? Alle Daten gehen verloren.", "Initialisieren")) return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
await storage.clear();
|
||||
window.location.reload();
|
||||
}}>Trotzdem initialisieren</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" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
|
||||
<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">
|
||||
<Section title="MITARBEITER-STANDARDS">
|
||||
<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" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
|
||||
<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">
|
||||
<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>
|
||||
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}>↓ Backup als JSON herunterladen</button>
|
||||
<label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}>
|
||||
↑ Backup importieren
|
||||
<input type="file" accept=".json" onChange={importData} style={{ display: "none" }} />
|
||||
</label>
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}>
|
||||
Neu initialisieren…
|
||||
</button>
|
||||
{!isCloudBackend && (
|
||||
<label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}>
|
||||
↑ Backup importieren
|
||||
<input type="file" accept=".json" onChange={importData} style={{ display: "none" }} />
|
||||
</label>
|
||||
)}
|
||||
{!isCloudBackend && (
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}>
|
||||
Neu initialisieren…
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="DATENBANKÜBERSICHT">
|
||||
@@ -864,6 +940,158 @@ export default function Settings({ data, update, currentUser, uiZoom, setUiZoom
|
||||
{/* ── Tab: Updates & Support ── */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user