import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react"; import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js"; import { migrateDashboardLayout, verifyPassword, withHashedPassword, stripCredentials } from "./utils.js"; import Login from "./views/Login.jsx"; import Setup from "./views/Setup.jsx"; import MigrationScreen from "./views/MigrationScreen.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
LADEN…
; } export default function App() { const [data, setData] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); let merged = { ...defaultData, ...parsed, settings: { ...defaultData.settings, ...parsed.settings } }; // Migrate: clients[] + contacts[] → persons[] if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) { const idMap = {}; const persons = []; const usedContactIds = new Set(); for (const c of merged.clients || []) { const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId); persons.push({ ...c, isAuftraggeber: true, isPartner: !!linked, type: c.type || linked?.type || "", note: c.note || linked?.note || "", honorarOffers: c.honorarOffers || linked?.honorarOffers || [], contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []), linkedContactId: undefined, linkedClientId: undefined, }); if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; } } for (const ct of merged.contacts || []) { if (usedContactIds.has(ct.id)) continue; persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined }); } const remapProjects = (merged.projects || []).map(p => ({ ...p, projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })), })); const remapProtocols = (merged.protocols || []).map(p => ({ ...p, entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })), })); merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined }; } // Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz) const allQuotes = merged.quotes || []; const projects = (merged.projects || []).map(p => { if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) { const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean); if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) { return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 }; } } return p; }); // Migrate: add r-projektleiter if missing, seed dashboardTemplateId from defaultData const roleDefMap = (defaultData.appRoles || []).reduce((acc, r) => { acc[r.id] = r; return acc; }, {}); const roles = (merged.appRoles || defaultData.appRoles).map(r => ({ ...r, dashboardTemplateId: r.dashboardTemplateId || roleDefMap[r.id]?.dashboardTemplateId || null, permissions: (() => { let perms = r.permissions; if (perms && r.id === "r-projektleiter" && !perms.includes("mitarbeiter")) perms = [...perms, "mitarbeiter"]; if (perms && !perms.includes("settings")) perms = [...perms, "settings"]; return perms; })(), })); if (!roles.find(r => r.id === "r-projektleiter") && roleDefMap["r-projektleiter"]) { const adminIdx = roles.findIndex(r => r.id === "r-admin"); roles.splice(adminIdx + 1, 0, roleDefMap["r-projektleiter"]); } // Migrate user-level dashboardWidgets to Row[] format const users = (merged.users || []).map(u => ({ ...u, dashboardWidgets: u.dashboardWidgets ? migrateDashboardLayout(u.dashboardWidgets) : undefined, })); // Ensure dashboardTemplates exist (old data won't have them) const dashboardTemplates = merged.dashboardTemplates?.length ? merged.dashboardTemplates : defaultData.dashboardTemplates; return { ...merged, projects, appRoles: roles, users, dashboardTemplates }; } } catch {} return defaultData; }); const [isNewInstall] = useState(() => !localStorage.getItem(STORAGE_KEY)); 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. // Async because PBKDF2 hashing happens off the main event loop via WebCrypto. // Legacy plaintext passwords are accepted ONCE and transparently upgraded to // PBKDF2 hashes on first successful login. const verifyLogin = async (username, password) => { const u = (data.users || []).find(x => x.username === username); 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); // fall through — still let the user in with legacy plaintext } } handleLogin(u); return u; }; const handleLogout = () => { sessionStorage.removeItem("rapport_user"); 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.6"); const [changelogVersion, setChangelogVersion] = useState("0.6"); 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); }, []); // 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); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); } catch {} }, []); const update = useCallback((key, value) => { save({ ...data, [key]: value }); }, [data, save]); // Auto-überfällig: einmal pro Tag prüfen (verhindert Endlos-Loop, da save() data ändert). const lastOverdueCheck = useRef(null); useEffect(() => { 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]); if (isNewInstall && !data.settings.setupCompleted) { return ; } if (!localStorage.getItem("rapport_v0_5_migrated")) { return ; } if (!currentUser) { 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.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.6"]; return (
CHANGELOG
Alpha {changelogVersion}
{versions.map(v => ( ))}
{current.items.map(([title, desc]) => (
{title}
{desc}
))}
); })()} {showAbout && (
ÜBER RAPPORT
Rapport
Alpha 0.6 · 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 ↗
)}
); }