Files
RAPPORT/src/App.jsx
T
karim gabriele varano 8de93ff27f Rapport 0.6 — Initial Public Release
Sicherheits-Hardening
- Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter
  Migration bestehender Klartext-Passwörter beim ersten Login
- Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare,
  Mindestpasswortlänge 8 Zeichen
- HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs,
  Event-Handler, Script-Tags; rel=noopener für target=_blank)
- Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben)
- Kryptografische IDs via crypto.randomUUID statt Math.random
- sessionStorage speichert keine Credentials mehr

GUI & Performance
- Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped)
- swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig
- Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung
- Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung
- Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills

Bug-Fixes
- Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:16:26 +02:00

785 lines
53 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 { 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 <div style={{ padding: 40, color: "var(--text4)", fontSize: 12, letterSpacing: "0.08em" }}>LADEN</div>;
}
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 <Setup onComplete={handleSetupComplete} />;
}
if (!localStorage.getItem("rapport_v0_5_migrated")) {
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
}
if (!currentUser) {
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.6" />;
}
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.6"); 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.6</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} setData={setData} 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>
{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 (
<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.6"); }}>
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.6 · 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>
);
}