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 (
); } 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); // true, wenn listStudios() fehlschlug (Kong/API nicht erreichbar). Wird // genutzt, um NICHT fälschlich den Init-/Registrierungs-Screen zu zeigen — // ein API-Fehler ist nicht dasselbe wie "Instanz hat 0 Studios". const [cloudUnreachable, setCloudUnreachable] = useState(false); // 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. // Bei Fehler (Kong/API down): NICHT [] setzen (das hieße "Instanz leer" → // fälschlich Init-Screen). Stattdessen Unreachable-Flag → Login bleibt. try { const list = await storage.listStudios?.(); if (!cancelled) { setCloudStudios(list || []); setCloudUnreachable(false); } } catch (e) { console.error("listStudios failed:", e); if (!cancelled) { setCloudStudios(null); setCloudUnreachable(true); } } 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.2"); const [changelogVersion, setChangelogVersion] = useState("0.8.2"); 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 ; // 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. // UpdateNotifier wird in allen Pre-Login-Screens mitgerendert, damit ein // hängender Setup-Wizard sich via Auto-Update selbst befreien kann. const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1"; if (!hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) { return <>; } // 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 <>; } if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) { return <>; } // Passwort-Reset hat höchste Priorität — User kommt von Mail-Link if (passwordRecovery) { return { 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. // UpdateNotifier wird hier auch gerendert, damit der Auto-Update-Check auch // ohne Login läuft (sonst kommt man bei einem fehlerhaften Setup-Screen nie // an ein neueres Build, das den Bug fixt). if (!currentUser) { // Init-/Registrierungs-Screen NUR wenn der API-Call erfolgreich war UND // wirklich 0 Studios lieferte. Bei !cloudUnreachable ausgeschlossen, dass // ein Kong/API-Fehler (cloudStudios === null) hier fälschlich Init zeigt. if (isCloudBackend && !cloudUnreachable && cloudStudios !== null && cloudStudios.length === 0) { const cloudUrl = localStorage.getItem("rapport_cloud_url") || ""; return <> ; } return <> ; } if (printContent) { return ( }> setPrintContent(null)} settings={data.settings} /> ); } return (
{/* Mobile Header */}
RAPPORT
{/* Sidebar */}
{collapsed ? ( ) : ( <> )}
{/* ── Vor / Zurück ── */}
{[["goBack", "‹", goBack, navCanBack, "Zurück"], ["goForward", "›", goForward, navCanForward, "Vorwärts"]].map(([key, ch, fn, enabled, title]) => ( ))}
{!collapsed &&
} {/* Benutzer + Logout + Theme */}
{!collapsed && (
{currentUserRecord?.avatar ? : userInitials}
{currentUser.displayName || currentUser.username}
{currentUser.role === "admin" &&
ADMIN
}
)}
{/* Main */}
}> {view === "dashboard" && } {view === "pinnwand" && } {view === "projects" && !selectedProjectId && } {view === "projects" && selectedProjectId && setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />} {view === "time" &&
{showChangelog && (() => { const CHANGELOGS = { "0.8.2": { items: [ ["Selbstheilung für hängende 0.8.0-Installationen", "Wer von 0.7 auf 0.8 geupdated hat und in den Cloud-Setup-Wizard geschoben wurde, kommt mit 0.8.2 automatisch zurück in seinen Lokal-Modus. Der Auto-Recovery-Code erkennt: Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Anmeldung → Cloud-Konfiguration wird zurückgenommen, alle Daten bleiben erhalten."], ["Auto-Update auch ohne Login", "Bisher prüfte Rapport erst nach dem Login auf Updates — wer in einem fehlerhaften Setup-Bildschirm hing, kam nicht an den Bugfix. Jetzt läuft der Update-Check auch im «Lokal oder Cloud»-Wizard, im Login-Screen und im Cloud-Setup."], ["Tauri ohne fest eingebaute Server-IP", "Die Desktop-App enthält keine vorkonfigurierte Cloud-Adresse mehr. Wer Cloud nutzen will, gibt die Server-Adresse beim Login aktiv ein — kein automatisches Vorausfüllen mit irrelevanten IPs."], ], }, "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.2"]; return (
CHANGELOG
Alpha {changelogVersion}
{versions.map(v => ( ))}
{current.items.map(([title, desc]) => (
{title}
{desc}
))}
); })()} {showAbout && (
ÜBER RAPPORT
Rapport
Alpha 0.8.2 · Studio-Management für Architekturbüros
LIZENZ
GNU AGPL-3.0
Rapport ist freie Software. Der Quellcode darf eingesehen, verändert und weitergegeben werden — unter den Bedingungen der AGPL-3.0-Lizenz.
www.gnu.org/licenses/agpl-3.0 ↗
TECHNOLOGIEN
{[ ["React 19", "UI-Framework"], ["Vite", "Build-Tool"], ["Tauri", "Desktop-App-Rahmen"], ["Material Symbols", "Icons von Google"], ].map(([name, desc]) => (
{name}
{desc}
))}
Entwickelt von Gabriele Varano
rapport.gabrielevarano.ch ↗
)}
); }