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:
2026-05-23 19:08:00 +02:00
parent c71feddf63
commit 27b1057cd4
35 changed files with 4668 additions and 151 deletions
+113
View File
@@ -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>
);
}
+238
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+88
View File
@@ -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
View File
@@ -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>
);
}