Files
RAPPORT/src/App.jsx
T
karim 40a28d5ff5 0.8.1: Fix Auto-Cloud-Switch beim Update von 0.7 auf 0.8
Beim Upgrade von 0.7 auf 0.8 wurden Lokal-Installationen ungewollt in den
Cloud-Modus geschoben, weil das Production-Build VITE_SUPABASE_URL enthielt
und adapter.js dies als Default-Cloud-URL gesetzt hat — auch wenn bereits
lokale Daten in localStorage vorhanden waren.

Fix in adapter.js: Auto-Cloud-Switch nur noch wenn
  - kein rapport_backend_chosen gesetzt
  - UND keine lokalen Daten (studio_data_v1) vorhanden
  - UND nicht in Tauri (Desktop-User wählen immer aktiv)

Damit klaut der Web-Deploy weiter automatisch Cloud-Konfig (kein BackendChoice
für Browser-User mit Server-URL), aber Tauri-Updates und Browser-User mit
bestehenden Lokal-Daten bleiben unangetastet.

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

1006 lines
63 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react";
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";
// Code-split: each view loads on demand to keep the initial bundle small.
const Dashboard = lazy(() => import("./views/Dashboard.jsx"));
const Projects = lazy(() => import("./views/Projects.jsx").then(m => ({ default: m.Projects })));
const ProjectDetail = lazy(() => import("./views/Projects.jsx").then(m => ({ default: m.ProjectDetail })));
const Time = lazy(() => import("./views/Time.jsx"));
const Expenses = lazy(() => import("./views/Expenses.jsx").then(m => ({ default: m.Expenses })));
const InternalExpenses = lazy(() => import("./views/Expenses.jsx").then(m => ({ default: m.InternalExpenses })));
const Protocols = lazy(() => import("./views/Protocols.jsx"));
const DeliveryNotes = lazy(() => import("./views/DeliveryNotes.jsx"));
const Accounting = lazy(() => import("./views/Accounting.jsx"));
const Invoices = lazy(() => import("./views/Invoices.jsx"));
const Quotes = lazy(() => import("./views/Quotes.jsx"));
const Persons = lazy(() => import("./views/Persons.jsx"));
const Letters = lazy(() => import("./views/Letters.jsx"));
const Settings = lazy(() => import("./views/Settings.jsx"));
const StudioBudget = lazy(() => import("./views/StudioBudget.jsx"));
const Payroll = lazy(() => import("./views/Payroll.jsx"));
const Employees = lazy(() => import("./views/Employees.jsx"));
const Pinboard = lazy(() => import("./views/Pinboard.jsx"));
const Documents = lazy(() => import("./views/Documents.jsx"));
const PrintView = lazy(() => import("./print/PrintComponents.jsx").then(m => ({ default: m.PrintView })));
function ViewFallback() {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "calc(100vh - 60px)" }}>
<style>{`
@keyframes vf-spin { to { transform: rotate(360deg); } }
.vf-spinner {
width: 40px; height: 40px; border-radius: 50%;
border: 2px solid rgba(0,0,0,0.08);
border-top-color: rgba(0,0,0,0.45);
animation: vf-spin 0.9s linear infinite;
}
`}</style>
<div className="vf-spinner" />
</div>
);
}
export default function App() {
// 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);
}
}
// 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;
}
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; }
});
const handleLogin = (user) => {
const safe = stripCredentials(user);
sessionStorage.setItem("rapport_user", JSON.stringify(safe));
setCurrentUser(safe);
};
// Used by the Login screen — never exposes the user list (with passwords) to the view.
// 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;
if (u.password && !u.passwordHash) {
try {
const upgraded = await withHashedPassword(u, password);
save({ ...data, users: (data.users || []).map(x => x.id === u.id ? upgraded : x) });
handleLogin(upgraded);
return upgraded;
} catch (e) {
console.error("Passwort-Migration fehlgeschlagen:", e);
}
}
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) => {
localStorage.setItem("rapport_v0_5_migrated", "1");
save(newData);
const adminUser = (newData.users || []).find(u => u.role === "admin");
if (adminUser) handleLogin(adminUser);
};
const userPermissions = (() => {
if (!currentUser || currentUser.role === "admin") return null;
const role = (data.appRoles || []).find(r => r.id === currentUser.appRoleId);
if (role) return role.permissions; // null = alle
return currentUser.permissions || null; // Fallback für alte Einträge ohne Rolle
})();
const currentUserRecord = (data.users || []).find(u => u.id === currentUser?.id);
const userInitials = (() => {
const parts = ((currentUser?.displayName || currentUser?.username) || "").trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return (parts[0] || "?")[0].toUpperCase();
})();
const visibleNavItems = userPermissions === null ? NAV_ITEMS : NAV_ITEMS.map(item => {
if (item.children) {
const ch = item.children.filter(c => userPermissions.includes(c.id));
return ch.length > 0 ? { ...item, children: ch } : null;
}
return userPermissions.includes(item.id) ? item : null;
}).filter(Boolean);
const allAccessibleViews = visibleNavItems.flatMap(item => item.children ? item.children.map(c => c.id) : [item.id]);
const [view, setView] = useState(() => {
if (!userPermissions) return "dashboard";
return userPermissions.includes("dashboard") ? "dashboard" : (userPermissions[0] || "dashboard");
});
const navHistRef = useRef([view]);
const navPosRef = useRef(0);
const [navCanBack, setNavCanBack] = useState(false);
const [navCanForward, setNavCanForward] = useState(false);
const navigate = (newView) => {
const pos = navPosRef.current;
const hist = navHistRef.current;
if (hist[pos] === newView) return;
const trimmed = [...hist.slice(0, pos + 1), newView];
navHistRef.current = trimmed;
navPosRef.current = trimmed.length - 1;
setView(newView);
setNavCanBack(true);
setNavCanForward(false);
};
const goBack = () => {
const pos = navPosRef.current;
if (pos <= 0) return;
navPosRef.current = pos - 1;
setView(navHistRef.current[pos - 1]);
setNavCanBack(pos - 1 > 0);
setNavCanForward(true);
};
const goForward = () => {
const pos = navPosRef.current;
const hist = navHistRef.current;
if (pos >= hist.length - 1) return;
navPosRef.current = pos + 1;
setView(hist[pos + 1]);
setNavCanBack(true);
setNavCanForward(pos + 1 < hist.length - 1);
};
const [selectedProjectId, setSelectedProjectId] = useState(null);
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.8.1");
const [changelogVersion, setChangelogVersion] = useState("0.8.1");
const [showAbout, setShowAbout] = useState(false);
const [navOpen, setNavOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("rapport_sidebar_collapsed") === "1");
const [isMobile, setIsMobile] = useState(() => window.matchMedia("(max-width: 768px)").matches);
useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)");
const handler = (e) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const collapsed = sidebarCollapsed && !isMobile;
const [uiZoom, setUiZoom] = useState(() => parseFloat(localStorage.getItem("rapport_zoom") || "1"));
// Persist dark mode
useEffect(() => { localStorage.setItem("rapport_dark", darkMode ? "1" : "0"); }, [darkMode]);
useEffect(() => { localStorage.setItem("rapport_sidebar_collapsed", sidebarCollapsed ? "1" : "0"); }, [sidebarCollapsed]);
// UI-Zoom: nur main-content, Sidebar bleibt unberührt
useEffect(() => {
localStorage.setItem("rapport_zoom", String(uiZoom));
// Tauri: native WebView-Zoom entfernen falls gesetzt (Sidebar-Problem)
if (window.__TAURI_INTERNALS__) {
import("@tauri-apps/api/webviewWindow")
.then(({ getCurrentWebviewWindow }) => getCurrentWebviewWindow().setZoom(1))
.catch(() => {});
}
}, [uiZoom]);
const zoomStep = 0.05;
const zoomIn = () => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100));
const zoomOut = () => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100));
// Navigation zu Protokoll von Projekt aus
useEffect(() => {
const handler = (e) => {
navigate("protokolle");
window.__openProtokoll = e.detail?.id || null;
};
window.addEventListener("openProtokoll", handler);
return () => window.removeEventListener("openProtokoll", handler);
}, []);
// Tray-Menü: „Zeiterfassung", „Projekte" usw. springen zur passenden View
useEffect(() => {
if (!window.__TAURI_INTERNALS__) return;
let unlisten = null;
import("@tauri-apps/api/event").then(({ listen }) => {
listen("rapport:navigate", (event) => {
const target = event.payload;
if (typeof target === "string") {
navigate(target);
setSelectedProjectId(null);
}
}).then((fn) => { unlisten = fn; });
});
return () => { if (unlisten) unlisten(); };
}, []);
// Auto-expand parent when navigating to a child
useEffect(() => {
NAV_ITEMS.forEach(item => {
if (item.children?.some(c => c.id === view)) {
setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; });
}
});
}, [view]);
const save = useCallback((newData) => {
setData(newData);
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;
const updated = data.invoices.map(inv =>
inv.status === "gesendet" && inv.dueDate && inv.dueDate < today
? { ...inv, status: "überfällig" } : inv
);
if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
save({ ...data, invoices: updated });
}, [data, save, loading]);
// 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 (!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) {
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.1" />;
}
if (printContent) {
return (
<Suspense fallback={<ViewFallback />}>
<PrintView content={printContent} onClose={() => setPrintContent(null)} settings={data.settings} />
</Suspense>
);
}
return (
<div className="app-wrapper" data-theme={darkMode ? "dark" : "light"} style={{ display: "flex", height: "100%", overflow: "hidden", background: "var(--bg)", fontFamily: "'DM Mono', 'Courier New', monospace", color: "var(--text)" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@400;700&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
.msr { font-family: 'Material Symbols Rounded'; font-weight: 300; font-style: normal; font-size: 20px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; direction: ltr; font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; user-select: none; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; }
:root, [data-theme=light] {
--bg: #ebe7e1;
--bg2: #e3dfd9;
--surface: #fdfcfa;
--surface2: #f7f4f0;
--surface3: #f0ece4;
--border: #ddd8d0;
--border2: #e6e1da;
--border3: #d8d2ca;
--text: #1a1a18;
--text2: #4a4844;
--text3: #6a6660;
--text4: #8c8880;
--text5: #b0aca4;
--input-bg: #fdfcfa;
--input-border: #c4bbb0;
--scrollbar-track: #e3dfd9;
--scrollbar-thumb: #b4aca0;
--accent: #b07848;
color-scheme: light;
}
[data-theme=dark] {
--bg: #161614;
--bg2: #1e1e1a;
--surface: #222220;
--surface2: #292924;
--surface3: #2e2e28;
--border: #38382e;
--border2: #2e2e28;
--border3: #333328;
--text: #e8e5df;
--text2: #b0aca4;
--text3: #9a968e;
--text4: #7a7670;
--text5: #565450;
--input-bg: #1e1e1a;
--input-border: #3a3a30;
--scrollbar-track: #1e1e1a;
--scrollbar-thumb: #3a3a30;
--accent: #e8e5df;
color-scheme: dark;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { width: 100%; height: 100%; margin: 0; padding: 0; background: #1a1a18; }
body, #root { width: 100%; height: 100%; margin: 0; padding: 0; background: var(--bg); color: var(--text); }
#root h1, #root h2, #root h3 { color: var(--text); -webkit-text-fill-color: var(--text); }
#root > div { width: 100% !important; max-width: 100% !important; }
#root, #root div, #root h1, #root h2, #root h3, #root p, #root label, #root span, #root nav, #root section, #root article, #root main, #root aside, #root header, #root footer { text-align: left; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
input, select, textarea, button { font-family: inherit; font-size: 13px; line-height: 1.4; margin: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; box-sizing: border-box; }
input, select, textarea { background: var(--input-bg); border: 1.5px solid var(--input-border); border-radius: 20px; padding: 8px 14px; color: var(--text); outline: none; transition: border-color 0.2s, box-shadow 0.2s; width: 100%; height: 36px; }
textarea { height: auto; min-height: 38px; line-height: 1.5; border-radius: 16px; padding: 10px 14px; }
select { padding-right: 28px; background-image: 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>"); background-repeat: no-repeat; background-position: right 10px center; cursor: pointer; }
input[type="checkbox"], input[type="radio"] { -webkit-appearance: auto; -moz-appearance: auto; appearance: auto; width: auto; height: auto; }
input[type="date"] { min-height: 36px; }
input:focus, select:focus, textarea:focus { border-color: #9a7858; box-shadow: 0 0 0 3px rgba(154,120,88,0.14); }
button { cursor: pointer; border: none; line-height: 1.4; }
.btn { padding: 0 20px; height: 36px; border-radius: 20px; font-weight: 600; font-size: 12px; letter-spacing: 0.02em; transition: all 0.18s; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; gap: 6px; }
.btn-primary { background: #252520; color: #f0ede8; box-shadow: 0 1px 3px rgba(0,0,0,0.18), 0 1px 2px rgba(0,0,0,0.12); }
.btn-primary:hover { background: #363630; box-shadow: 0 2px 8px rgba(0,0,0,0.28); }
.btn-danger { background: #8a1a1a; color: #fff; border-radius: 20px; }
.btn-danger:hover { background: #a02020; box-shadow: 0 2px 8px rgba(138,26,26,0.25); }
.btn-ghost { background: var(--surface); color: var(--text2); border: 1.5px solid var(--border3); border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
.btn-ghost:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
.filter-bar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.filter-label { font-size: 11px; color: var(--text4); letter-spacing: 0.06em; white-space: nowrap; font-weight: 500; }
.pill { border-radius: 20px !important; border: 1.5px solid var(--border3); font-size: 12px; height: 32px; white-space: nowrap; transition: all 0.15s; box-sizing: border-box; }
button.pill { background: var(--surface); color: var(--text3); cursor: pointer; font-family: inherit; padding: 0 14px; display: inline-flex; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
button.pill:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
button.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
input.pill, select.pill { width: auto; min-width: 100px; padding: 0 14px; }
select.pill { padding-right: 28px; }
.btn-sm { height: 28px !important; padding: 0 10px !important; font-size: 12px !important; }
.section-label { font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; margin-bottom: 12px; display: block; }
.panel-label { padding: 12px 16px; border-bottom: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
.section-divider { margin: 14px 0 10px; padding-top: 12px; border-top: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.08em; color: var(--text4); text-transform: uppercase; display: block; }
.empty-state { text-align: center; color: var(--text4); padding: 32px !important; font-size: 13px; }
.card { background: var(--surface); border-radius: 16px; border: 1px solid var(--border2); padding: 22px 24px; overflow-x: auto; box-shadow: 0 1px 2px rgba(0,0,0,0.07), 0 4px 20px rgba(0,0,0,0.06); }
.tag { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 10px; font-weight: 600; color: #fff; letter-spacing: 0.06em; text-transform: uppercase; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 10px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; padding: 10px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border2); color: var(--text); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); transition: background 0.12s; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; backdrop-filter: blur(4px); }
.modal { background: var(--surface); border-radius: 24px; padding: 32px 36px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border2); box-shadow: 0 8px 48px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.08); }
.form-row { display: flex; gap: 14px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; flex: 1; }
label { font-size: 10px; letter-spacing: 0.09em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
@media print { body { background: white !important; } .no-print { display: none !important; } }
.mobile-header { display: none; background: #1a1a18; padding: 14px 18px; position: sticky; top: 0; z-index: 150; align-items: center; gap: 12px; }
.sidebar-logo-btn { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.sidebar-logo-btn:hover .sidebar-logo-text { opacity: 0.6; }
.sidebar-logo-text { transition: opacity 0.15s; }
.nav-btn { transition: all 0.15s; }
.nav-btn:hover { background: rgba(240,237,232,0.06) !important; }
.nav-pill { margin: 2px 8px; border-radius: 28px !important; width: calc(100% - 16px) !important; }
.nav-pill-active { background: #2e2e28 !important; }
.nav-pill:hover { background: rgba(240,237,232,0.08) !important; }
.mobile-col-toggle { display: none; }
@media (max-width: 768px) {
.sidebar { display: none !important; }
.sidebar.open { display: flex !important; position: fixed; inset: 0; z-index: 200; width: 100% !important; height: 100vh; overflow-y: auto; }
.sidebar-logo-btn { pointer-events: none !important; }
.main-content { padding: 16px !important; }
.mobile-header { display: flex !important; }
.mobile-close { display: block !important; }
.mobile-col-toggle { display: inline-flex !important; }
.app-wrapper { flex-direction: column !important; }
.form-row { flex-direction: column !important; }
.card { padding: 14px !important; }
.modal { padding: 18px !important; margin: 10px; }
.responsive-grid-2 { grid-template-columns: 1fr !important; }
.responsive-grid-4 { grid-template-columns: 1fr 1fr !important; }
.dashboard-grid > * { grid-column: span 12 !important; }
h1 { font-size: 26px !important; }
table { min-width: 500px; }
.hide-mobile { display: none !important; }
.hide-compact { display: none !important; }
}
`}</style>
{/* Mobile Header */}
<div className="mobile-header">
<button onClick={() => setNavOpen(o => !o)} style={{ background: "none", border: "none", color: "#aaa", fontSize: 22, padding: 0, lineHeight: 1 }}></button>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 24, color: "#f0ede8", letterSpacing: "-0.02em", lineHeight: 1 }}>RAPPORT</div>
</div>
{/* Sidebar */}
<div className={`sidebar${navOpen ? " open" : ""}`} style={{ width: collapsed ? 56 : 210, background: "#1a1a18", display: "flex", flexDirection: "column", padding: "33px 0", alignSelf: "stretch", margin: "15px 15px 15px 0", flexShrink: 0, overflowY: "auto", borderRadius: "0 16px 16px 0", border: "1px solid #3a3a34", boxShadow: "6px 0 20px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.12), 0 -6px 16px rgba(0,0,0,0.12)", clipPath: "inset(-14px -100px -14px 0)", transition: "width 0.2s ease", overflow: "hidden" }}>
<div style={{ padding: collapsed ? "0 0 28px" : "0 22px 28px", borderBottom: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", transition: "padding 0.2s" }}>
{collapsed ? (
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(false)} title="Sidebar ausklappen">
<div className="sidebar-logo-text" style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 22, color: "#f0ede8", lineHeight: 1, letterSpacing: "-0.02em" }}>R</div>
</button>
) : (
<>
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(true)} title="Sidebar einklappen">
<div className="sidebar-logo-text">
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 28, color: "#f0ede8", lineHeight: 0.95, letterSpacing: "-0.02em" }}>RAPPORT</div>
<div style={{ fontSize: 9, color: "#4a4840", marginTop: 8, letterSpacing: "0.15em", fontWeight: 500 }}>{(data.settings.name || "STUDIO ADMINISTRATION").toUpperCase()}</div>
</div>
</button>
<button onClick={() => setNavOpen(false)} style={{ background: "none", border: "none", color: "#555", fontSize: 20, padding: 0, lineHeight: 1, display: "none" }} className="mobile-close"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</>
)}
</div>
<nav style={{ flex: 1, padding: "18px 0" }}>
{visibleNavItems.map(item => {
const isParentActive = view === item.id || (item.children || []).some(c => c.id === view);
const isExpanded = expandedNav.has(item.id);
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedNav(prev => {
const next = new Set(prev);
next.has(item.id) ? next.delete(item.id) : next.add(item.id);
return next;
});
};
if (item.children) {
if (collapsed) {
return (
<button key={item.id} title={item.label} onClick={() => { navigate(item.id); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: "100%", padding: "11px 0",
background: isParentActive ? "#252520" : "transparent",
border: "none", cursor: "pointer",
color: isParentActive ? "#e8e5df" : "#555", transition: "all 0.15s",
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
);
}
return (
<div key={item.id} style={{ padding: "2px 8px" }}>
<div style={{ display: "flex", alignItems: "center", borderRadius: 28, background: isParentActive ? "#2e2e28" : "transparent", transition: "background 0.15s" }}>
<button className="nav-btn" onClick={() => { navigate(item.id); setNavOpen(false); setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; }); }} style={{
flex: 1, padding: "10px 0 10px 14px",
background: "transparent", border: "none",
color: isParentActive ? "#e8e5df" : "#aaa",
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, borderRadius: "28px 0 0 28px",
}}>
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: isParentActive ? 1 : 0.6 }}>{item.icon}</span>
{item.label}
</button>
<button className="nav-btn" onClick={toggleExpand} style={{
flexShrink: 0, width: 36, alignSelf: "stretch",
display: "flex", alignItems: "center", justifyContent: "center",
background: "transparent", border: "none", cursor: "pointer",
color: isExpanded ? "#bbb" : "#555", fontSize: 8,
fontFamily: "inherit", padding: 0, borderRadius: "0 28px 28px 0",
}}>
<span style={{ display: "inline-block", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}></span>
</button>
</div>
{isExpanded && (
<div style={{ paddingTop: 2, paddingBottom: 4 }}>
{item.children.map(child => (
<button key={child.id} className="nav-btn" onClick={() => { navigate(child.id); setNavOpen(false); }} style={{
display: "block", width: "100%", padding: "7px 14px 7px 42px",
background: view === child.id ? "#222220" : "transparent",
color: view === child.id ? "#d8d5cf" : "#777",
fontSize: 10, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
border: "none", letterSpacing: "0.07em", textTransform: "uppercase", fontWeight: 500,
borderRadius: 20,
}}>{child.label}</button>
))}
</div>
)}
</div>
);
}
if (collapsed) {
return (
<button key={item.id} title={item.label} className="nav-btn" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: "100%", padding: "11px 0",
background: view === item.id ? "#2e2e28" : "transparent",
border: "none", cursor: "pointer",
color: view === item.id ? "#e8e5df" : "#555",
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
);
}
return (
<button key={item.id} className="nav-btn nav-pill" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "10px 14px",
background: view === item.id ? "#2e2e28" : "transparent",
color: view === item.id ? "#e8e5df" : "#aaa",
border: "none", fontFamily: "inherit", cursor: "pointer",
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, fontSize: 11,
}}>
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: view === item.id ? 1 : 0.6 }}>{item.icon}</span>
{item.label}
</button>
);
})}
</nav>
{/* ── Vor / Zurück ── */}
<div style={{ borderTop: "1px solid #2d2d28", display: "flex", gap: 2, padding: collapsed ? "8px 0" : "6px 10px", justifyContent: collapsed ? "center" : "flex-start" }}>
{[["goBack", "", goBack, navCanBack, "Zurück"], ["goForward", "", goForward, navCanForward, "Vorwärts"]].map(([key, ch, fn, enabled, title]) => (
<button key={key} onClick={enabled ? fn : undefined} title={title} style={{ background: "none", border: "none", color: enabled ? "#888" : "#333", cursor: enabled ? "pointer" : "default", fontSize: 20, lineHeight: 1, padding: "4px 9px", fontFamily: "inherit", borderRadius: 6, transition: "color 0.15s" }}
onMouseEnter={e => { if (enabled) e.currentTarget.style.color = "#ccc"; }}
onMouseLeave={e => { e.currentTarget.style.color = enabled ? "#888" : "#333"; }}>
{ch}
</button>
))}
</div>
{!collapsed && <div style={{ padding: "8px 16px", borderTop: "1px solid #2d2d28" }}>
<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.8.1"); 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.1</button>
</div>
</div>}
{/* Benutzer + Logout + Theme */}
<div style={{ padding: collapsed ? "10px 0" : "10px 16px", borderTop: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", gap: 8 }}>
{!collapsed && (
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#2e2e28", border: "1.5px solid #3a3a30", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600, color: "#9a9690", flexShrink: 0, overflow: "hidden", letterSpacing: "0.02em" }}>
{currentUserRecord?.avatar
? <img src={currentUserRecord.avatar} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: userInitials}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, color: "#aaa", fontWeight: 500, letterSpacing: "0.04em", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{currentUser.displayName || currentUser.username}
</div>
{currentUser.role === "admin" && <div style={{ fontSize: 8, color: "#555", letterSpacing: "0.1em" }}>ADMIN</div>}
</div>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
<button onClick={() => setDarkMode(d => !d)} title={darkMode ? "Light Mode" : "Dark Mode"} style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s" }}
onMouseEnter={e => e.currentTarget.style.color = "#ccc"}
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
<span className="msr" style={{ fontSize: 18 }}>{darkMode ? "light_mode" : "dark_mode"}</span>
</button>
<button onClick={handleLogout} title="Abmelden" style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s", flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = "#b5621e"}
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
<span className="msr" style={{ fontSize: 18 }}>logout</span>
</button>
</div>
</div>
</div>
{/* Main */}
<div className="main-content" style={{ flex: 1, padding: "28px 24px", overflowY: "auto", minWidth: 0, background: "var(--bg)", zoom: uiZoom !== 1 ? uiZoom : undefined }}>
<Suspense fallback={<ViewFallback />}>
{view === "dashboard" && <Dashboard data={data} setView={navigate} currentUser={currentUser} saveAll={save} />}
{view === "pinnwand" && <Pinboard data={data} update={update} currentUser={currentUser} />}
{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} 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} />}
{view === "buchhaltung" && <Accounting data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
{view === "invoices" && <Invoices data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} />}
{view === "loehne" && <Payroll data={data} update={update} saveAll={save} setPrintContent={setPrintContent} setView={navigate} />}
{view === "expenses" && <Expenses data={data} update={update} saveAll={save} modal={modal} setModal={setModal} standalone setView={navigate} />}
{view === "internal-expenses" && <InternalExpenses data={data} update={update} setView={navigate} />}
{view === "personen" && <Persons data={data} update={update} saveAll={save} setView={navigate} />}
{view === "mitarbeiter" && <Employees data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "letters" && <Letters data={data} update={update} setPrintContent={setPrintContent} />}
{view === "settings" && <Settings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom} setUiZoom={setUiZoom} />}
{view === "studio-budget" && <StudioBudget data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
</Suspense>
</div>
<UpdateNotifier />
{showChangelog && (() => {
const CHANGELOGS = {
"0.8.1": {
items: [
["Update-Fix", "Behebt einen Fehler beim Upgrade von 0.7 auf 0.8: Lokal-Installationen wurden ungewollt in den Cloud-Modus geschoben und der Cloud-Setup-Wizard angezeigt, obwohl bereits lokale Daten vorhanden waren. Die App prüft jetzt vor einem automatischen Modus-Wechsel, ob lokale Daten existieren — und in Tauri-Installationen wird der Modus nie implizit gesetzt."],
],
},
"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."],
["System-Tray-Icon", "Rapport läuft im Hintergrund weiter, wenn das Fenster geschlossen wird, und ist über ein Menüleisten-Icon erreichbar. Schnellzugriff auf Dashboard, Zeiterfassung, Projekte und Buchhaltung; Cmd+Q beendet die App vollständig."],
["Einstellungen: Updates & Support", "Neuer Tab «Updates & Support» mit manueller Update-Suche, Zeitstempel der letzten Prüfung und Link zur Dokumentation auf rapport.kgva.ch."],
],
},
"0.6": {
items: [
["Sicherheit: Passwort-Hashing", "Passwörter werden jetzt mit PBKDF2 (SHA-256, 100 000 Iterationen) und einem zufälligen Salt gespeichert. Bestehende Klartext-Passwörter werden beim ersten erfolgreichen Login transparent migriert."],
["Login: Brute-Force-Schutz", "Nach 5 Fehlversuchen wird der Login für 60 Sekunden gesperrt — mit sichtbarem Countdown. Passwortvergleich erfolgt in konstanter Zeit, Mindestlänge bei der Einrichtung auf 8 Zeichen erhöht."],
["Briefe: HTML-Sanitizer", "Brieftexte werden vor dem Druck durch einen Allowlist-Sanitizer geleitet. Script-Tags, javascript:-URLs und on*-Handler werden entfernt — externe Links bekommen automatisch rel=\"noopener noreferrer\"."],
["Datenexport ohne Klartext-Passwörter", "Beim Sichern der Daten werden Legacy-Klartext-Passwörter entfernt; nur die nicht umkehrbaren PBKDF2-Hashes bleiben im Backup."],
["Belege direkt in der Zeiterfassung", "Spesenbelege (Bild oder PDF) können jetzt direkt aus der Tagesansicht hochgeladen und angezeigt werden. Bilder werden vor dem Speichern auf 1600 px skaliert und JPEG-komprimiert."],
["Avatar-Upload schlanker", "Profilbilder werden auf 256 px skaliert und als JPEG gespeichert — localStorage bleibt klein. Nur Bilddateien werden akzeptiert."],
["Schnellerer Start", "Module werden per Code-Splitting nur bei Bedarf geladen — die App startet spürbar schneller und braucht weniger Speicher."],
["QR-Rechnung offline", "Die swissqrbill-Bibliothek ist jetzt lokal eingebunden — QR-Einzahlungsscheine funktionieren ohne Internet-Verbindung."],
["Stabilere IDs", "Neue Datensätze nutzen kryptografisch zufällige IDs (crypto.randomUUID) statt Math.random."],
["Über Rapport & UI-Feinschliff", "Neues «Über Rapport»-Modal mit Lizenzinfo, einheitliche Stift-Icons zum Bearbeiten, Pinnwand-Kategorien als Pills."],
],
},
"0.5": {
items: [
["Anmeldesystem", "Benutzerverwaltung mit Rollen und Passwörtern. Jeder Mitarbeiter erhält einen eigenen Login. Berechtigungen steuern, welche Module sichtbar sind."],
["Migration bestehender Daten", "Beim ersten Start mit einer bestehenden Datenbank erscheint ein Migrations-Assistent: Daten sichern, Admin-Konto einrichten — alle bisherigen Inhalte bleiben erhalten."],
["Rechnungstypen: Akonto / Teilrechnung / Schluss", "Klare Trennung der Rechnungsarten mit unterschiedlicher steuerlicher Behandlung. Akonto ist erst bei der Schlussrechnung steuerrelevant; Teilrechnungen sind sofort wirksam."],
["Neuer Rechnungsdialog", "Zweistufige Auswahl: zuerst Art der Rechnung (Akonto / Teilrechnung / Schlussrechnung), dann Berechnungsmethode (Stunden / % vom Budget / Fixer Betrag / SIA-Phase)."],
["Akonto & Teilrechnung nach SIA-Phase", "Für Pauschal-Projekte können einzelne SIA-Phasen direkt verrechnet werden — bereits verrechnete Phasen werden als solche markiert."],
["Aufwandsrechnungen erweitert", "Stundenprojekte unterstützen jetzt Akonto (Stunden bis heute, %, Fixbetrag) und Teilrechnung (Stunden auswählen, %, Fixbetrag, SIA-Phase)."],
["Buchhaltung: Akonto-MwSt getrennt", "Akonto-Rechnungen werden in der Buchhaltung separat ausgewiesen — die MwSt wird erst bei der Schlussrechnung als steuerrelevant gezählt."],
["Mitarbeiter: Intern & Absenzen", "Umbenennung zu «Intern / Absenzen». Neue Jahresübersicht mit Monatsvergleich und Vorjahr, Absenzkategorien-Matrix, sowie Auswertung interner Stunden ohne Projektbezug."],
],
},
"0.4": {
items: [
["Material Design 3", "Sidebar mit einklappbarem Icon-Modus und Material Symbols Rounded Icons. Buttons als Pill, Inputs und Cards mit mehr Radius, Tags als Chips, Modals mit Backdrop-Blur."],
["Interne Projektbeteiligung", "Mitarbeitende können direkt im Projekt zugewiesen werden. In Protokollen erscheinen unter «Intern» nur noch die zugewiesenen Personen."],
["Offerten im Budget", "Neben Rechnungen können jetzt auch Offerten in die Einnahmen-Planung des Bürobudgets einbezogen werden."],
["Kategorien direkt auf der Seite", "Spesenarten und Ausgaben-Kategorien werden neu direkt auf der jeweiligen Seite verwaltet, nicht mehr in den Einstellungen."],
],
},
"0.3": {
items: [
["Visuelles Redesign", "Sidebar schwebt als eigenständiges Panel mit Radius und Schatten. Cards haben mehr Tiefe. Header kompakter, Abstände überarbeitet, Menüpunkte in Kapitälchen. Studio-Name in der Sidebar."],
["Zoom", "Der UI-Zoom betrifft nur den Hauptinhalt — die Sidebar bleibt immer gleich gross und unverzerrt."],
],
},
"0.2": {
items: [
["Neue Module", "Lohnabrechnung, Bürobudget, Protokolle, Lieferscheine und Spesen (Mitarbeiter & intern) hinzugefügt."],
["Zeiterfassung Wochenansicht", "Visuelles Zeitraster mit Drag & Drop — Einträge verschieben und skalieren, Wechsel zwischen Tag-, Wochen- und Monatsansicht."],
["Ferienplanung", "Ferienanträge stellen, genehmigen und zurückziehen. Absenzverwaltung ausgebaut."],
["Teilzeit", "Lohn wird proportional zum Pensum berechnet, pro Monat überschreibbar."],
],
},
"0.1": {
items: [
["Erster Release", "Projekte, Kunden, Mitarbeiterverwaltung, Zeiterfassung."],
["Rechnungen & Offerten", "Rechnungen mit QR-Einzahlungsschein, Offerten nach SIA 102, manuell oder frei — konvertierbar zur Rechnung."],
["Einstellungen", "Studio-Stammdaten, Stundensätze, Rollen, MwSt und Projektnummern-Format."],
],
},
};
const versions = Object.keys(CHANGELOGS);
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.1"];
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" }}>
<div style={{ background: "#1a1a18", padding: "28px 32px 20px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>CHANGELOG</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 16 }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Alpha {changelogVersion}</div>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 14 }}>
{versions.map(v => (
<button key={v} onClick={() => setChangelogVersion(v)} style={{ fontSize: 10, padding: "3px 10px", borderRadius: 20, border: "1px solid", borderColor: v === changelogVersion ? "#b07848" : "#3a3a30", background: v === changelogVersion ? "#b07848" : "transparent", color: v === changelogVersion ? "#1a1a18" : "#888", cursor: "pointer", fontFamily: "inherit", letterSpacing: "0.06em" }}>
Alpha {v}
</button>
))}
</div>
</div>
<div style={{ padding: "20px 32px 8px", maxHeight: 340, overflowY: "auto" }}>
{current.items.map(([title, desc]) => (
<div key={title} style={{ display: "flex", gap: 14, marginBottom: 14 }}>
<div style={{ width: 4, flexShrink: 0, background: "#b07848", borderRadius: 2, marginTop: 2 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>{desc}</div>
</div>
</div>
))}
</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.8.1"); }}>
Schliessen
</button>
</div>
</div>
</div>
);
})()}
{showAbout && (
<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" }}>
<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.8.1 · 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>
<div style={{ display: "flex", gap: 14, marginBottom: 14 }}>
<div style={{ width: 4, flexShrink: 0, background: "#b07848", borderRadius: 2 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>GNU AGPL-3.0</div>
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>Rapport ist freie Software. Der Quellcode darf eingesehen, verändert und weitergegeben werden unter den Bedingungen der AGPL-3.0-Lizenz.</div>
<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noreferrer" style={{ fontSize: 11, color: "#b07848", textDecoration: "none", display: "inline-block", marginTop: 4 }}>www.gnu.org/licenses/agpl-3.0 </a>
</div>
</div>
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12, marginTop: 20 }}>TECHNOLOGIEN</div>
{[
["React 19", "UI-Framework"],
["Vite", "Build-Tool"],
["Tauri", "Desktop-App-Rahmen"],
["Material Symbols", "Icons von Google"],
].map(([name, desc]) => (
<div key={name} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: "1px solid #f0ede8" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "#1a1a18" }}>{name}</div>
<div style={{ fontSize: 12, color: "#aaa" }}>{desc}</div>
</div>
))}
<div style={{ marginTop: 20, padding: "12px 14px", background: "#f7f5f2", borderRadius: 8 }}>
<div style={{ fontSize: 11, color: "#888", lineHeight: 1.6 }}>
Entwickelt von <span style={{ color: "#1a1a18", fontWeight: 600 }}>Gabriele Varano</span><br />
<a href="https://rapport.gabrielevarano.ch/" target="_blank" rel="noreferrer" style={{ color: "#b07848", textDecoration: "none" }}>rapport.gabrielevarano.ch </a>
</div>
</div>
</div>
<div style={{ padding: "16px 32px 24px" }}>
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => setShowAbout(false)}>Schliessen</button>
</div>
</div>
</div>
)}
</div>
);
}