40a28d5ff5
Beim Upgrade von 0.7 auf 0.8 wurden Lokal-Installationen ungewollt in den Cloud-Modus geschoben, weil das Production-Build VITE_SUPABASE_URL enthielt und adapter.js dies als Default-Cloud-URL gesetzt hat — auch wenn bereits lokale Daten in localStorage vorhanden waren. Fix in adapter.js: Auto-Cloud-Switch nur noch wenn - kein rapport_backend_chosen gesetzt - UND keine lokalen Daten (studio_data_v1) vorhanden - UND nicht in Tauri (Desktop-User wählen immer aktiv) Damit klaut der Web-Deploy weiter automatisch Cloud-Konfig (kein BackendChoice für Browser-User mit Server-URL), aber Tauri-Updates und Browser-User mit bestehenden Lokal-Daten bleiben unangetastet. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1006 lines
63 KiB
React
Executable File
1006 lines
63 KiB
React
Executable File
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react";
|
||
import { NAV_ITEMS, defaultData } from "./constants.js";
|
||
import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js";
|
||
import { storage, isCloudBackend } from "./storage/adapter.js";
|
||
import { applyMigrations } from "./storage/migrations.js";
|
||
import Login from "./views/Login.jsx";
|
||
import Setup from "./views/Setup.jsx";
|
||
import BackendChoice from "./views/BackendChoice.jsx";
|
||
import CloudSetup from "./views/CloudSetup.jsx";
|
||
import ResetPassword from "./views/ResetPassword.jsx";
|
||
import MigrationScreen from "./views/MigrationScreen.jsx";
|
||
import UpdateNotifier from "./components/UpdateNotifier.jsx";
|
||
|
||
// Code-split: each view loads on demand to keep the initial bundle small.
|
||
const Dashboard = lazy(() => import("./views/Dashboard.jsx"));
|
||
const Projects = lazy(() => import("./views/Projects.jsx").then(m => ({ default: m.Projects })));
|
||
const ProjectDetail = lazy(() => import("./views/Projects.jsx").then(m => ({ default: m.ProjectDetail })));
|
||
const Time = lazy(() => import("./views/Time.jsx"));
|
||
const Expenses = lazy(() => import("./views/Expenses.jsx").then(m => ({ default: m.Expenses })));
|
||
const InternalExpenses = lazy(() => import("./views/Expenses.jsx").then(m => ({ default: m.InternalExpenses })));
|
||
const Protocols = lazy(() => import("./views/Protocols.jsx"));
|
||
const DeliveryNotes = lazy(() => import("./views/DeliveryNotes.jsx"));
|
||
const Accounting = lazy(() => import("./views/Accounting.jsx"));
|
||
const Invoices = lazy(() => import("./views/Invoices.jsx"));
|
||
const Quotes = lazy(() => import("./views/Quotes.jsx"));
|
||
const Persons = lazy(() => import("./views/Persons.jsx"));
|
||
const Letters = lazy(() => import("./views/Letters.jsx"));
|
||
const Settings = lazy(() => import("./views/Settings.jsx"));
|
||
const StudioBudget = lazy(() => import("./views/StudioBudget.jsx"));
|
||
const Payroll = lazy(() => import("./views/Payroll.jsx"));
|
||
const Employees = lazy(() => import("./views/Employees.jsx"));
|
||
const Pinboard = lazy(() => import("./views/Pinboard.jsx"));
|
||
const Documents = lazy(() => import("./views/Documents.jsx"));
|
||
const PrintView = lazy(() => import("./print/PrintComponents.jsx").then(m => ({ default: m.PrintView })));
|
||
|
||
function ViewFallback() {
|
||
return (
|
||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "calc(100vh - 60px)" }}>
|
||
<style>{`
|
||
@keyframes vf-spin { to { transform: rotate(360deg); } }
|
||
.vf-spinner {
|
||
width: 40px; height: 40px; border-radius: 50%;
|
||
border: 2px solid rgba(0,0,0,0.08);
|
||
border-top-color: rgba(0,0,0,0.45);
|
||
animation: vf-spin 0.9s linear infinite;
|
||
}
|
||
`}</style>
|
||
<div className="vf-spinner" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
// Initial-Load läuft asynchron, damit derselbe Pfad später Cloud-Backends bedienen kann.
|
||
// `data` wird mit defaultData initialisiert (statt null), damit alle synchronen Reads
|
||
// wie `data.appRoles` während des Initial-Renders nicht crashen. `loading` zeigt den
|
||
// Boot-Spinner, bis der echte Snapshot da ist (LocalStorage <50ms, Cloud ggf. länger).
|
||
const [data, setData] = useState(defaultData);
|
||
const [loading, setLoading] = useState(true);
|
||
const [isNewInstall, setIsNewInstall] = useState(false);
|
||
// Cloud-spezifisch: Liste der Studios auf der Instanz (für Erst-Setup-Check).
|
||
// null = noch nicht geladen; Array = geladen.
|
||
const [cloudStudios, setCloudStudios] = useState(null);
|
||
// Passwort-Reset-Flow: Supabase löst beim Klick auf Reset-Link in der Mail
|
||
// ein PASSWORD_RECOVERY-Event aus → wir zeigen dann das Reset-Formular.
|
||
const [passwordRecovery, setPasswordRecovery] = useState(false);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
if (isCloudBackend) {
|
||
// Cloud-Reload-Fall: wenn sowohl Session (sessionStorage.rapport_user)
|
||
// als auch das gewählte Studio (rapport_studio_id) noch vorhanden sind,
|
||
// stellen wir den Adapter wieder her und laden direkt — ohne dass der
|
||
// User sich neu einloggen muss.
|
||
const sessionUser = (() => {
|
||
try { return JSON.parse(sessionStorage.getItem("rapport_user")); } catch { return null; }
|
||
})();
|
||
const savedStudioId = sessionStorage.getItem("rapport_studio_id");
|
||
if (sessionUser && savedStudioId) {
|
||
storage.setStudioId(savedStudioId);
|
||
try {
|
||
const parsed = await storage.load();
|
||
if (!cancelled && parsed) {
|
||
setData(applyMigrations(parsed, defaultData));
|
||
}
|
||
} catch (e) {
|
||
console.error("Cloud-Resume load failed:", e);
|
||
}
|
||
}
|
||
// Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt
|
||
try {
|
||
const list = await storage.listStudios?.();
|
||
if (!cancelled) setCloudStudios(list || []);
|
||
} catch (e) {
|
||
console.error("listStudios failed:", e);
|
||
if (!cancelled) setCloudStudios([]);
|
||
}
|
||
if (!cancelled) setLoading(false);
|
||
return;
|
||
}
|
||
const hasData = await storage.hasExistingData();
|
||
if (cancelled) return;
|
||
setIsNewInstall(!hasData);
|
||
const parsed = await storage.load();
|
||
if (cancelled) return;
|
||
setData(parsed ? applyMigrations(parsed, defaultData) : defaultData);
|
||
setLoading(false);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
const [currentUser, setCurrentUser] = useState(() => {
|
||
try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; }
|
||
});
|
||
const handleLogin = (user) => {
|
||
const safe = stripCredentials(user);
|
||
sessionStorage.setItem("rapport_user", JSON.stringify(safe));
|
||
setCurrentUser(safe);
|
||
};
|
||
// Used by the Login screen — never exposes the user list (with passwords) to the view.
|
||
// Routes by backend: cloud → Supabase Auth, local → PBKDF2 gegen data.users.
|
||
const verifyLogin = async (usernameOrEmail, password, opts = {}) => {
|
||
if (isCloudBackend) return cloudSignIn(usernameOrEmail, password, opts.studioId);
|
||
|
||
const u = (data.users || []).find(x => x.username === usernameOrEmail);
|
||
if (!u) return null;
|
||
const ok = await verifyPassword(password, u);
|
||
if (!ok) return null;
|
||
if (u.password && !u.passwordHash) {
|
||
try {
|
||
const upgraded = await withHashedPassword(u, password);
|
||
save({ ...data, users: (data.users || []).map(x => x.id === u.id ? upgraded : x) });
|
||
handleLogin(upgraded);
|
||
return upgraded;
|
||
} catch (e) {
|
||
console.error("Passwort-Migration fehlgeschlagen:", e);
|
||
}
|
||
}
|
||
handleLogin(u);
|
||
return u;
|
||
};
|
||
|
||
// Cloud-Erst-Einrichtung: einmaliger Bootstrap-Pfad, wenn die Cloud-Instanz
|
||
// noch leer ist (0 Studios). Im laufenden Betrieb gibt es keinen Self-Signup
|
||
// — Mitarbeiter werden via Admin-Aktion eingeladen.
|
||
// `extraSettings` enthält optionale Stammdaten (Adresse, IBAN, MwSt, …),
|
||
// die nach createStudio in die studio_settings geschrieben werden.
|
||
const cloudInit = async (email, password, displayName, studioName, extraSettings = {}) => {
|
||
try {
|
||
const signUpRes = await storage.signUp(email, password);
|
||
if (!signUpRes.ok) return { ok: false, error: signUpRes.error };
|
||
|
||
const username = (email.split("@")[0] || "user").replace(/[^a-zA-Z0-9._-]/g, "");
|
||
await storage.ensureProfile(username, displayName);
|
||
|
||
const baseSlug = studioName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
||
const slug = `${baseSlug || "studio"}-${Date.now().toString(36)}`;
|
||
const studioId = await storage.createStudio(studioName, slug);
|
||
|
||
storage.setStudioId(studioId);
|
||
sessionStorage.setItem("rapport_studio_id", studioId);
|
||
|
||
const parsed = await storage.load();
|
||
let merged = applyMigrations(parsed, defaultData);
|
||
|
||
// Optionale Stammdaten anwenden + sofort speichern, damit sie persistiert
|
||
// sind, bevor der User irgendwo durchklickt (sonst geht beim nächsten
|
||
// Reload Adresse/IBAN verloren).
|
||
if (Object.keys(extraSettings).length > 0) {
|
||
merged = { ...merged, settings: { ...merged.settings, ...extraSettings } };
|
||
await storage.save(merged);
|
||
}
|
||
setData(merged);
|
||
|
||
handleLogin({
|
||
id: signUpRes.user.id,
|
||
username,
|
||
displayName,
|
||
role: "admin",
|
||
appRoleId: "r-admin",
|
||
});
|
||
return { ok: true };
|
||
} catch (e) {
|
||
console.error("Cloud init fehlgeschlagen:", e);
|
||
return { ok: false, error: e?.message || "Unbekannter Fehler" };
|
||
}
|
||
};
|
||
|
||
// Cloud-Login: signIn → Studio wählen (Dropdown im Login oder erstes) → data laden.
|
||
// `preferredStudioId` kommt aus dem Login-Dropdown wenn Multi-Studio-Instanz.
|
||
const cloudSignIn = async (email, password, preferredStudioId = null) => {
|
||
try {
|
||
const result = await storage.signIn(email, password);
|
||
if (!result) return null;
|
||
if (result.studios.length === 0) {
|
||
console.warn("Cloud-Login OK, aber User ist in keinem Studio Mitglied.");
|
||
return null;
|
||
}
|
||
// Wenn ein Studio vorgewählt wurde, prüfen ob der User dort Member ist
|
||
const membership = preferredStudioId
|
||
? result.studios.find(m => m.studio_id === preferredStudioId)
|
||
: result.studios[0];
|
||
if (!membership) {
|
||
console.warn("User ist im gewählten Studio nicht Mitglied.");
|
||
return null;
|
||
}
|
||
storage.setStudioId(membership.studio_id);
|
||
sessionStorage.setItem("rapport_studio_id", membership.studio_id);
|
||
const parsed = await storage.load();
|
||
setData(applyMigrations(parsed, defaultData));
|
||
const user = {
|
||
id: result.user.id,
|
||
username: result.profile?.username || result.user.email,
|
||
displayName: result.profile?.display_name || result.user.email,
|
||
role: membership.app_role_id === "r-admin" ? "admin" : "user",
|
||
appRoleId: membership.app_role_id,
|
||
};
|
||
handleLogin(user);
|
||
return user;
|
||
} catch (e) {
|
||
console.error("Cloud signIn fehlgeschlagen:", e);
|
||
return null;
|
||
}
|
||
};
|
||
const handleLogout = () => {
|
||
sessionStorage.removeItem("rapport_user");
|
||
sessionStorage.removeItem("rapport_studio_id");
|
||
if (isCloudBackend) {
|
||
storage.signOut?.().catch(() => {});
|
||
}
|
||
setCurrentUser(null);
|
||
};
|
||
const handleSetupComplete = (newData) => {
|
||
localStorage.setItem("rapport_v0_5_migrated", "1");
|
||
save(newData);
|
||
const adminUser = (newData.users || []).find(u => u.role === "admin");
|
||
if (adminUser) handleLogin(adminUser);
|
||
};
|
||
const userPermissions = (() => {
|
||
if (!currentUser || currentUser.role === "admin") return null;
|
||
const role = (data.appRoles || []).find(r => r.id === currentUser.appRoleId);
|
||
if (role) return role.permissions; // null = alle
|
||
return currentUser.permissions || null; // Fallback für alte Einträge ohne Rolle
|
||
})();
|
||
const currentUserRecord = (data.users || []).find(u => u.id === currentUser?.id);
|
||
const userInitials = (() => {
|
||
const parts = ((currentUser?.displayName || currentUser?.username) || "").trim().split(/\s+/).filter(Boolean);
|
||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||
return (parts[0] || "?")[0].toUpperCase();
|
||
})();
|
||
const visibleNavItems = userPermissions === null ? NAV_ITEMS : NAV_ITEMS.map(item => {
|
||
if (item.children) {
|
||
const ch = item.children.filter(c => userPermissions.includes(c.id));
|
||
return ch.length > 0 ? { ...item, children: ch } : null;
|
||
}
|
||
return userPermissions.includes(item.id) ? item : null;
|
||
}).filter(Boolean);
|
||
const allAccessibleViews = visibleNavItems.flatMap(item => item.children ? item.children.map(c => c.id) : [item.id]);
|
||
const [view, setView] = useState(() => {
|
||
if (!userPermissions) return "dashboard";
|
||
return userPermissions.includes("dashboard") ? "dashboard" : (userPermissions[0] || "dashboard");
|
||
});
|
||
const navHistRef = useRef([view]);
|
||
const navPosRef = useRef(0);
|
||
const [navCanBack, setNavCanBack] = useState(false);
|
||
const [navCanForward, setNavCanForward] = useState(false);
|
||
|
||
const navigate = (newView) => {
|
||
const pos = navPosRef.current;
|
||
const hist = navHistRef.current;
|
||
if (hist[pos] === newView) return;
|
||
const trimmed = [...hist.slice(0, pos + 1), newView];
|
||
navHistRef.current = trimmed;
|
||
navPosRef.current = trimmed.length - 1;
|
||
setView(newView);
|
||
setNavCanBack(true);
|
||
setNavCanForward(false);
|
||
};
|
||
const goBack = () => {
|
||
const pos = navPosRef.current;
|
||
if (pos <= 0) return;
|
||
navPosRef.current = pos - 1;
|
||
setView(navHistRef.current[pos - 1]);
|
||
setNavCanBack(pos - 1 > 0);
|
||
setNavCanForward(true);
|
||
};
|
||
const goForward = () => {
|
||
const pos = navPosRef.current;
|
||
const hist = navHistRef.current;
|
||
if (pos >= hist.length - 1) return;
|
||
navPosRef.current = pos + 1;
|
||
setView(hist[pos + 1]);
|
||
setNavCanBack(true);
|
||
setNavCanForward(pos + 1 < hist.length - 1);
|
||
};
|
||
|
||
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
||
const [modal, setModal] = useState(null);
|
||
const [printContent, setPrintContent] = useState(null);
|
||
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
|
||
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8.1");
|
||
const [changelogVersion, setChangelogVersion] = useState("0.8.1");
|
||
const [showAbout, setShowAbout] = useState(false);
|
||
const [navOpen, setNavOpen] = useState(false);
|
||
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("rapport_sidebar_collapsed") === "1");
|
||
const [isMobile, setIsMobile] = useState(() => window.matchMedia("(max-width: 768px)").matches);
|
||
useEffect(() => {
|
||
const mq = window.matchMedia("(max-width: 768px)");
|
||
const handler = (e) => setIsMobile(e.matches);
|
||
mq.addEventListener("change", handler);
|
||
return () => mq.removeEventListener("change", handler);
|
||
}, []);
|
||
const collapsed = sidebarCollapsed && !isMobile;
|
||
const [uiZoom, setUiZoom] = useState(() => parseFloat(localStorage.getItem("rapport_zoom") || "1"));
|
||
|
||
// Persist dark mode
|
||
useEffect(() => { localStorage.setItem("rapport_dark", darkMode ? "1" : "0"); }, [darkMode]);
|
||
useEffect(() => { localStorage.setItem("rapport_sidebar_collapsed", sidebarCollapsed ? "1" : "0"); }, [sidebarCollapsed]);
|
||
|
||
// UI-Zoom: nur main-content, Sidebar bleibt unberührt
|
||
useEffect(() => {
|
||
localStorage.setItem("rapport_zoom", String(uiZoom));
|
||
// Tauri: native WebView-Zoom entfernen falls gesetzt (Sidebar-Problem)
|
||
if (window.__TAURI_INTERNALS__) {
|
||
import("@tauri-apps/api/webviewWindow")
|
||
.then(({ getCurrentWebviewWindow }) => getCurrentWebviewWindow().setZoom(1))
|
||
.catch(() => {});
|
||
}
|
||
}, [uiZoom]);
|
||
|
||
const zoomStep = 0.05;
|
||
const zoomIn = () => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100));
|
||
const zoomOut = () => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100));
|
||
|
||
|
||
// Navigation zu Protokoll von Projekt aus
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
navigate("protokolle");
|
||
window.__openProtokoll = e.detail?.id || null;
|
||
};
|
||
window.addEventListener("openProtokoll", handler);
|
||
return () => window.removeEventListener("openProtokoll", handler);
|
||
}, []);
|
||
|
||
// Tray-Menü: „Zeiterfassung", „Projekte" usw. springen zur passenden View
|
||
useEffect(() => {
|
||
if (!window.__TAURI_INTERNALS__) return;
|
||
let unlisten = null;
|
||
import("@tauri-apps/api/event").then(({ listen }) => {
|
||
listen("rapport:navigate", (event) => {
|
||
const target = event.payload;
|
||
if (typeof target === "string") {
|
||
navigate(target);
|
||
setSelectedProjectId(null);
|
||
}
|
||
}).then((fn) => { unlisten = fn; });
|
||
});
|
||
return () => { if (unlisten) unlisten(); };
|
||
}, []);
|
||
|
||
// Auto-expand parent when navigating to a child
|
||
useEffect(() => {
|
||
NAV_ITEMS.forEach(item => {
|
||
if (item.children?.some(c => c.id === view)) {
|
||
setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; });
|
||
}
|
||
});
|
||
}, [view]);
|
||
|
||
const save = useCallback((newData) => {
|
||
setData(newData);
|
||
storage.save(newData).catch(e => console.error("Storage save failed:", e));
|
||
}, []);
|
||
|
||
const update = useCallback((key, value) => {
|
||
save({ ...data, [key]: value });
|
||
}, [data, save]);
|
||
|
||
// Cloud: Passwort-Reset-Event abfangen — der Klick auf den Mail-Link führt
|
||
// zurück zur App mit hash-Token. Zwei Pfade:
|
||
// 1. URL-Hash beim Mount checken (Supabase JS parsed schon vor useEffect)
|
||
// 2. onAuthStateChange als Fallback
|
||
useEffect(() => {
|
||
if (!isCloudBackend || !storage.client) return;
|
||
if (typeof window !== "undefined" && window.location.hash.includes("type=recovery")) {
|
||
setPasswordRecovery(true);
|
||
}
|
||
const { data: sub } = storage.client.auth.onAuthStateChange((event) => {
|
||
if (event === "PASSWORD_RECOVERY") setPasswordRecovery(true);
|
||
});
|
||
return () => { sub?.subscription?.unsubscribe?.(); };
|
||
}, []);
|
||
|
||
// Cloud: Realtime-Subscription + Refresh bei Tab-Focus / Visibility-Change.
|
||
// Damit sieht User A live, wenn User B im anderen Browser was ändert — und
|
||
// wenn der Tab im Hintergrund war, holen wir beim Zurückkommen den aktuellen
|
||
// Stand. Refresh ist debounced (500ms), damit Batch-Inserts nicht 25 Loads
|
||
// hintereinander triggern.
|
||
useEffect(() => {
|
||
if (!isCloudBackend || !currentUser) return;
|
||
let cancelled = false;
|
||
let timer = null;
|
||
|
||
const refresh = async () => {
|
||
try {
|
||
const parsed = await storage.load();
|
||
if (!cancelled && parsed) {
|
||
setData(applyMigrations(parsed, defaultData));
|
||
}
|
||
} catch (e) {
|
||
console.error("Cloud refresh failed:", e);
|
||
}
|
||
};
|
||
|
||
const scheduleRefresh = () => {
|
||
if (timer) clearTimeout(timer);
|
||
timer = setTimeout(() => { timer = null; refresh(); }, 500);
|
||
};
|
||
|
||
storage.subscribeToChanges?.(scheduleRefresh);
|
||
|
||
const onVisibility = () => {
|
||
if (document.visibilityState === "visible") scheduleRefresh();
|
||
};
|
||
document.addEventListener("visibilitychange", onVisibility);
|
||
window.addEventListener("focus", scheduleRefresh);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (timer) clearTimeout(timer);
|
||
storage.unsubscribeFromChanges?.();
|
||
document.removeEventListener("visibilitychange", onVisibility);
|
||
window.removeEventListener("focus", scheduleRefresh);
|
||
};
|
||
}, [currentUser]);
|
||
|
||
// Auto-überfällig: einmal pro Tag prüfen (verhindert Endlos-Loop, da save() data ändert).
|
||
const lastOverdueCheck = useRef(null);
|
||
useEffect(() => {
|
||
if (loading) return;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
if (lastOverdueCheck.current === today) return;
|
||
lastOverdueCheck.current = today;
|
||
const updated = data.invoices.map(inv =>
|
||
inv.status === "gesendet" && inv.dueDate && inv.dueDate < today
|
||
? { ...inv, status: "überfällig" } : inv
|
||
);
|
||
if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
|
||
save({ ...data, invoices: updated });
|
||
}, [data, save, loading]);
|
||
|
||
// Boot-Spinner während Initial-Load (Adapter ist async, auch wenn LocalStorage <50ms)
|
||
if (loading) return <ViewFallback />;
|
||
|
||
// Erst-Screen einer frischen Installation: «Lokal oder Cloud?».
|
||
// Sichtbar solange der User die Wahl noch nicht getroffen hat UND es keine
|
||
// lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard.
|
||
const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1";
|
||
if (!hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) {
|
||
return <BackendChoice />;
|
||
}
|
||
|
||
// Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus
|
||
// erfolgt Erst-Einrichtung über den Init-Dialog im Login.
|
||
if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) {
|
||
return <Setup onComplete={handleSetupComplete} />;
|
||
}
|
||
|
||
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
|
||
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
|
||
}
|
||
|
||
// Passwort-Reset hat höchste Priorität — User kommt von Mail-Link
|
||
if (passwordRecovery) {
|
||
return <ResetPassword
|
||
onComplete={async (newPw) => {
|
||
try {
|
||
const { error } = await storage.client.auth.updateUser({ password: newPw });
|
||
if (error) return { ok: false, error: error.message };
|
||
return { ok: true };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message || "Fehler" };
|
||
}
|
||
}}
|
||
onCancel={async () => {
|
||
await storage.client?.auth?.signOut?.();
|
||
setPasswordRecovery(false);
|
||
if (window.location.hash) window.location.hash = "";
|
||
}}
|
||
/>;
|
||
}
|
||
|
||
// Cloud + Instanz ist leer (0 Studios) → mehrseitiger Setup-Wizard.
|
||
// Cloud + Studios vorhanden → klassischer Login.
|
||
if (!currentUser) {
|
||
if (isCloudBackend && cloudStudios !== null && cloudStudios.length === 0) {
|
||
const cloudUrl = localStorage.getItem("rapport_cloud_url") || "";
|
||
return <CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />;
|
||
}
|
||
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.8.1" />;
|
||
}
|
||
|
||
if (printContent) {
|
||
return (
|
||
<Suspense fallback={<ViewFallback />}>
|
||
<PrintView content={printContent} onClose={() => setPrintContent(null)} settings={data.settings} />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="app-wrapper" data-theme={darkMode ? "dark" : "light"} style={{ display: "flex", height: "100%", overflow: "hidden", background: "var(--bg)", fontFamily: "'DM Mono', 'Courier New', monospace", color: "var(--text)" }}>
|
||
<style>{`
|
||
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@400;700&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
|
||
.msr { font-family: 'Material Symbols Rounded'; font-weight: 300; font-style: normal; font-size: 20px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; direction: ltr; font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; user-select: none; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; }
|
||
|
||
:root, [data-theme=light] {
|
||
--bg: #ebe7e1;
|
||
--bg2: #e3dfd9;
|
||
--surface: #fdfcfa;
|
||
--surface2: #f7f4f0;
|
||
--surface3: #f0ece4;
|
||
--border: #ddd8d0;
|
||
--border2: #e6e1da;
|
||
--border3: #d8d2ca;
|
||
--text: #1a1a18;
|
||
--text2: #4a4844;
|
||
--text3: #6a6660;
|
||
--text4: #8c8880;
|
||
--text5: #b0aca4;
|
||
--input-bg: #fdfcfa;
|
||
--input-border: #c4bbb0;
|
||
--scrollbar-track: #e3dfd9;
|
||
--scrollbar-thumb: #b4aca0;
|
||
--accent: #b07848;
|
||
color-scheme: light;
|
||
}
|
||
[data-theme=dark] {
|
||
--bg: #161614;
|
||
--bg2: #1e1e1a;
|
||
--surface: #222220;
|
||
--surface2: #292924;
|
||
--surface3: #2e2e28;
|
||
--border: #38382e;
|
||
--border2: #2e2e28;
|
||
--border3: #333328;
|
||
--text: #e8e5df;
|
||
--text2: #b0aca4;
|
||
--text3: #9a968e;
|
||
--text4: #7a7670;
|
||
--text5: #565450;
|
||
--input-bg: #1e1e1a;
|
||
--input-border: #3a3a30;
|
||
--scrollbar-track: #1e1e1a;
|
||
--scrollbar-thumb: #3a3a30;
|
||
--accent: #e8e5df;
|
||
color-scheme: dark;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html { width: 100%; height: 100%; margin: 0; padding: 0; background: #1a1a18; }
|
||
body, #root { width: 100%; height: 100%; margin: 0; padding: 0; background: var(--bg); color: var(--text); }
|
||
#root h1, #root h2, #root h3 { color: var(--text); -webkit-text-fill-color: var(--text); }
|
||
#root > div { width: 100% !important; max-width: 100% !important; }
|
||
#root, #root div, #root h1, #root h2, #root h3, #root p, #root label, #root span, #root nav, #root section, #root article, #root main, #root aside, #root header, #root footer { text-align: left; }
|
||
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||
input, select, textarea, button { font-family: inherit; font-size: 13px; line-height: 1.4; margin: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; box-sizing: border-box; }
|
||
input, select, textarea { background: var(--input-bg); border: 1.5px solid var(--input-border); border-radius: 20px; padding: 8px 14px; color: var(--text); outline: none; transition: border-color 0.2s, box-shadow 0.2s; width: 100%; height: 36px; }
|
||
textarea { height: auto; min-height: 38px; line-height: 1.5; border-radius: 16px; padding: 10px 14px; }
|
||
select { padding-right: 28px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; cursor: pointer; }
|
||
input[type="checkbox"], input[type="radio"] { -webkit-appearance: auto; -moz-appearance: auto; appearance: auto; width: auto; height: auto; }
|
||
input[type="date"] { min-height: 36px; }
|
||
input:focus, select:focus, textarea:focus { border-color: #9a7858; box-shadow: 0 0 0 3px rgba(154,120,88,0.14); }
|
||
button { cursor: pointer; border: none; line-height: 1.4; }
|
||
.btn { padding: 0 20px; height: 36px; border-radius: 20px; font-weight: 600; font-size: 12px; letter-spacing: 0.02em; transition: all 0.18s; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; gap: 6px; }
|
||
.btn-primary { background: #252520; color: #f0ede8; box-shadow: 0 1px 3px rgba(0,0,0,0.18), 0 1px 2px rgba(0,0,0,0.12); }
|
||
.btn-primary:hover { background: #363630; box-shadow: 0 2px 8px rgba(0,0,0,0.28); }
|
||
.btn-danger { background: #8a1a1a; color: #fff; border-radius: 20px; }
|
||
.btn-danger:hover { background: #a02020; box-shadow: 0 2px 8px rgba(138,26,26,0.25); }
|
||
.btn-ghost { background: var(--surface); color: var(--text2); border: 1.5px solid var(--border3); border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
|
||
.btn-ghost:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
|
||
.filter-bar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
|
||
.filter-label { font-size: 11px; color: var(--text4); letter-spacing: 0.06em; white-space: nowrap; font-weight: 500; }
|
||
.pill { border-radius: 20px !important; border: 1.5px solid var(--border3); font-size: 12px; height: 32px; white-space: nowrap; transition: all 0.15s; box-sizing: border-box; }
|
||
button.pill { background: var(--surface); color: var(--text3); cursor: pointer; font-family: inherit; padding: 0 14px; display: inline-flex; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
|
||
button.pill:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
|
||
button.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||
input.pill, select.pill { width: auto; min-width: 100px; padding: 0 14px; }
|
||
select.pill { padding-right: 28px; }
|
||
.btn-sm { height: 28px !important; padding: 0 10px !important; font-size: 12px !important; }
|
||
|
||
.section-label { font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; margin-bottom: 12px; display: block; }
|
||
.panel-label { padding: 12px 16px; border-bottom: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
|
||
.section-divider { margin: 14px 0 10px; padding-top: 12px; border-top: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.08em; color: var(--text4); text-transform: uppercase; display: block; }
|
||
.empty-state { text-align: center; color: var(--text4); padding: 32px !important; font-size: 13px; }
|
||
.card { background: var(--surface); border-radius: 16px; border: 1px solid var(--border2); padding: 22px 24px; overflow-x: auto; box-shadow: 0 1px 2px rgba(0,0,0,0.07), 0 4px 20px rgba(0,0,0,0.06); }
|
||
.tag { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 10px; font-weight: 600; color: #fff; letter-spacing: 0.06em; text-transform: uppercase; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { text-align: left; font-size: 10px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; padding: 10px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
|
||
td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border2); color: var(--text); }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: var(--surface2); transition: background 0.12s; }
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; backdrop-filter: blur(4px); }
|
||
.modal { background: var(--surface); border-radius: 24px; padding: 32px 36px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border2); box-shadow: 0 8px 48px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.08); }
|
||
.form-row { display: flex; gap: 14px; margin-bottom: 16px; }
|
||
.form-group { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||
label { font-size: 10px; letter-spacing: 0.09em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
|
||
@media print { body { background: white !important; } .no-print { display: none !important; } }
|
||
.mobile-header { display: none; background: #1a1a18; padding: 14px 18px; position: sticky; top: 0; z-index: 150; align-items: center; gap: 12px; }
|
||
.sidebar-logo-btn { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||
.sidebar-logo-btn:hover .sidebar-logo-text { opacity: 0.6; }
|
||
.sidebar-logo-text { transition: opacity 0.15s; }
|
||
.nav-btn { transition: all 0.15s; }
|
||
.nav-btn:hover { background: rgba(240,237,232,0.06) !important; }
|
||
.nav-pill { margin: 2px 8px; border-radius: 28px !important; width: calc(100% - 16px) !important; }
|
||
.nav-pill-active { background: #2e2e28 !important; }
|
||
.nav-pill:hover { background: rgba(240,237,232,0.08) !important; }
|
||
.mobile-col-toggle { display: none; }
|
||
@media (max-width: 768px) {
|
||
.sidebar { display: none !important; }
|
||
.sidebar.open { display: flex !important; position: fixed; inset: 0; z-index: 200; width: 100% !important; height: 100vh; overflow-y: auto; }
|
||
.sidebar-logo-btn { pointer-events: none !important; }
|
||
.main-content { padding: 16px !important; }
|
||
.mobile-header { display: flex !important; }
|
||
.mobile-close { display: block !important; }
|
||
.mobile-col-toggle { display: inline-flex !important; }
|
||
.app-wrapper { flex-direction: column !important; }
|
||
.form-row { flex-direction: column !important; }
|
||
.card { padding: 14px !important; }
|
||
.modal { padding: 18px !important; margin: 10px; }
|
||
.responsive-grid-2 { grid-template-columns: 1fr !important; }
|
||
.responsive-grid-4 { grid-template-columns: 1fr 1fr !important; }
|
||
.dashboard-grid > * { grid-column: span 12 !important; }
|
||
h1 { font-size: 26px !important; }
|
||
table { min-width: 500px; }
|
||
.hide-mobile { display: none !important; }
|
||
.hide-compact { display: none !important; }
|
||
}
|
||
`}</style>
|
||
|
||
{/* Mobile Header */}
|
||
<div className="mobile-header">
|
||
<button onClick={() => setNavOpen(o => !o)} style={{ background: "none", border: "none", color: "#aaa", fontSize: 22, padding: 0, lineHeight: 1 }}>☰</button>
|
||
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 24, color: "#f0ede8", letterSpacing: "-0.02em", lineHeight: 1 }}>RAPPORT</div>
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className={`sidebar${navOpen ? " open" : ""}`} style={{ width: collapsed ? 56 : 210, background: "#1a1a18", display: "flex", flexDirection: "column", padding: "33px 0", alignSelf: "stretch", margin: "15px 15px 15px 0", flexShrink: 0, overflowY: "auto", borderRadius: "0 16px 16px 0", border: "1px solid #3a3a34", boxShadow: "6px 0 20px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.12), 0 -6px 16px rgba(0,0,0,0.12)", clipPath: "inset(-14px -100px -14px 0)", transition: "width 0.2s ease", overflow: "hidden" }}>
|
||
<div style={{ padding: collapsed ? "0 0 28px" : "0 22px 28px", borderBottom: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", transition: "padding 0.2s" }}>
|
||
{collapsed ? (
|
||
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(false)} title="Sidebar ausklappen">
|
||
<div className="sidebar-logo-text" style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 22, color: "#f0ede8", lineHeight: 1, letterSpacing: "-0.02em" }}>R</div>
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(true)} title="Sidebar einklappen">
|
||
<div className="sidebar-logo-text">
|
||
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 28, color: "#f0ede8", lineHeight: 0.95, letterSpacing: "-0.02em" }}>RAPPORT</div>
|
||
<div style={{ fontSize: 9, color: "#4a4840", marginTop: 8, letterSpacing: "0.15em", fontWeight: 500 }}>{(data.settings.name || "STUDIO ADMINISTRATION").toUpperCase()}</div>
|
||
</div>
|
||
</button>
|
||
<button onClick={() => setNavOpen(false)} style={{ background: "none", border: "none", color: "#555", fontSize: 20, padding: 0, lineHeight: 1, display: "none" }} className="mobile-close"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<nav style={{ flex: 1, padding: "18px 0" }}>
|
||
{visibleNavItems.map(item => {
|
||
const isParentActive = view === item.id || (item.children || []).some(c => c.id === view);
|
||
const isExpanded = expandedNav.has(item.id);
|
||
const toggleExpand = (e) => {
|
||
e.stopPropagation();
|
||
setExpandedNav(prev => {
|
||
const next = new Set(prev);
|
||
next.has(item.id) ? next.delete(item.id) : next.add(item.id);
|
||
return next;
|
||
});
|
||
};
|
||
if (item.children) {
|
||
if (collapsed) {
|
||
return (
|
||
<button key={item.id} title={item.label} onClick={() => { navigate(item.id); setNavOpen(false); }} style={{
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
width: "100%", padding: "11px 0",
|
||
background: isParentActive ? "#252520" : "transparent",
|
||
border: "none", cursor: "pointer",
|
||
color: isParentActive ? "#e8e5df" : "#555", transition: "all 0.15s",
|
||
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
|
||
);
|
||
}
|
||
return (
|
||
<div key={item.id} style={{ padding: "2px 8px" }}>
|
||
<div style={{ display: "flex", alignItems: "center", borderRadius: 28, background: isParentActive ? "#2e2e28" : "transparent", transition: "background 0.15s" }}>
|
||
<button className="nav-btn" onClick={() => { navigate(item.id); setNavOpen(false); setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; }); }} style={{
|
||
flex: 1, padding: "10px 0 10px 14px",
|
||
background: "transparent", border: "none",
|
||
color: isParentActive ? "#e8e5df" : "#aaa",
|
||
display: "flex", alignItems: "center", gap: 10,
|
||
fontSize: 11, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
|
||
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, borderRadius: "28px 0 0 28px",
|
||
}}>
|
||
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: isParentActive ? 1 : 0.6 }}>{item.icon}</span>
|
||
{item.label}
|
||
</button>
|
||
<button className="nav-btn" onClick={toggleExpand} style={{
|
||
flexShrink: 0, width: 36, alignSelf: "stretch",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
background: "transparent", border: "none", cursor: "pointer",
|
||
color: isExpanded ? "#bbb" : "#555", fontSize: 8,
|
||
fontFamily: "inherit", padding: 0, borderRadius: "0 28px 28px 0",
|
||
}}>
|
||
<span style={{ display: "inline-block", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>▶</span>
|
||
</button>
|
||
</div>
|
||
{isExpanded && (
|
||
<div style={{ paddingTop: 2, paddingBottom: 4 }}>
|
||
{item.children.map(child => (
|
||
<button key={child.id} className="nav-btn" onClick={() => { navigate(child.id); setNavOpen(false); }} style={{
|
||
display: "block", width: "100%", padding: "7px 14px 7px 42px",
|
||
background: view === child.id ? "#222220" : "transparent",
|
||
color: view === child.id ? "#d8d5cf" : "#777",
|
||
fontSize: 10, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
|
||
border: "none", letterSpacing: "0.07em", textTransform: "uppercase", fontWeight: 500,
|
||
borderRadius: 20,
|
||
}}>{child.label}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (collapsed) {
|
||
return (
|
||
<button key={item.id} title={item.label} className="nav-btn" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
width: "100%", padding: "11px 0",
|
||
background: view === item.id ? "#2e2e28" : "transparent",
|
||
border: "none", cursor: "pointer",
|
||
color: view === item.id ? "#e8e5df" : "#555",
|
||
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
|
||
);
|
||
}
|
||
return (
|
||
<button key={item.id} className="nav-btn nav-pill" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
|
||
display: "flex", alignItems: "center", gap: 10,
|
||
padding: "10px 14px",
|
||
background: view === item.id ? "#2e2e28" : "transparent",
|
||
color: view === item.id ? "#e8e5df" : "#aaa",
|
||
border: "none", fontFamily: "inherit", cursor: "pointer",
|
||
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, fontSize: 11,
|
||
}}>
|
||
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: view === item.id ? 1 : 0.6 }}>{item.icon}</span>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
{/* ── Vor / Zurück ── */}
|
||
<div style={{ borderTop: "1px solid #2d2d28", display: "flex", gap: 2, padding: collapsed ? "8px 0" : "6px 10px", justifyContent: collapsed ? "center" : "flex-start" }}>
|
||
{[["goBack", "‹", goBack, navCanBack, "Zurück"], ["goForward", "›", goForward, navCanForward, "Vorwärts"]].map(([key, ch, fn, enabled, title]) => (
|
||
<button key={key} onClick={enabled ? fn : undefined} title={title} style={{ background: "none", border: "none", color: enabled ? "#888" : "#333", cursor: enabled ? "pointer" : "default", fontSize: 20, lineHeight: 1, padding: "4px 9px", fontFamily: "inherit", borderRadius: 6, transition: "color 0.15s" }}
|
||
onMouseEnter={e => { if (enabled) e.currentTarget.style.color = "#ccc"; }}
|
||
onMouseLeave={e => { e.currentTarget.style.color = enabled ? "#888" : "#333"; }}>
|
||
{ch}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{!collapsed && <div style={{ padding: "8px 16px", borderTop: "1px solid #2d2d28" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<button onClick={() => setShowAbout(true)} style={{ background: "none", border: "none", padding: 0, color: "#555", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }}
|
||
onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button>
|
||
<button onClick={() => { setChangelogVersion("0.8.1"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit" }}
|
||
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8.1</button>
|
||
</div>
|
||
</div>}
|
||
|
||
{/* Benutzer + Logout + Theme */}
|
||
<div style={{ padding: collapsed ? "10px 0" : "10px 16px", borderTop: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", gap: 8 }}>
|
||
{!collapsed && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#2e2e28", border: "1.5px solid #3a3a30", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600, color: "#9a9690", flexShrink: 0, overflow: "hidden", letterSpacing: "0.02em" }}>
|
||
{currentUserRecord?.avatar
|
||
? <img src={currentUserRecord.avatar} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||
: userInitials}
|
||
</div>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 10, color: "#aaa", fontWeight: 500, letterSpacing: "0.04em", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||
{currentUser.displayName || currentUser.username}
|
||
</div>
|
||
{currentUser.role === "admin" && <div style={{ fontSize: 8, color: "#555", letterSpacing: "0.1em" }}>ADMIN</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
|
||
<button onClick={() => setDarkMode(d => !d)} title={darkMode ? "Light Mode" : "Dark Mode"} style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s" }}
|
||
onMouseEnter={e => e.currentTarget.style.color = "#ccc"}
|
||
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
|
||
<span className="msr" style={{ fontSize: 18 }}>{darkMode ? "light_mode" : "dark_mode"}</span>
|
||
</button>
|
||
<button onClick={handleLogout} title="Abmelden" style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s", flexShrink: 0 }}
|
||
onMouseEnter={e => e.currentTarget.style.color = "#b5621e"}
|
||
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
|
||
<span className="msr" style={{ fontSize: 18 }}>logout</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main */}
|
||
<div className="main-content" style={{ flex: 1, padding: "28px 24px", overflowY: "auto", minWidth: 0, background: "var(--bg)", zoom: uiZoom !== 1 ? uiZoom : undefined }}>
|
||
<Suspense fallback={<ViewFallback />}>
|
||
{view === "dashboard" && <Dashboard data={data} setView={navigate} currentUser={currentUser} saveAll={save} />}
|
||
{view === "pinnwand" && <Pinboard data={data} update={update} currentUser={currentUser} />}
|
||
{view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} currentUser={currentUser} />}
|
||
{view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />}
|
||
{view === "time" && <Time data={data} update={update} currentUser={currentUser} setPrintContent={setPrintContent} />}
|
||
{view === "quotes" && <Quotes data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />}
|
||
{view === "dokumente" && <Documents data={data} setView={navigate} />}
|
||
{view === "lieferscheine" && <DeliveryNotes data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||
{view === "protokolle" && <Protocols data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||
{view === "buchhaltung" && <Accounting data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
|
||
{view === "invoices" && <Invoices data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} />}
|
||
{view === "loehne" && <Payroll data={data} update={update} saveAll={save} setPrintContent={setPrintContent} setView={navigate} />}
|
||
{view === "expenses" && <Expenses data={data} update={update} saveAll={save} modal={modal} setModal={setModal} standalone setView={navigate} />}
|
||
{view === "internal-expenses" && <InternalExpenses data={data} update={update} setView={navigate} />}
|
||
{view === "personen" && <Persons data={data} update={update} saveAll={save} setView={navigate} />}
|
||
{view === "mitarbeiter" && <Employees data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||
{view === "letters" && <Letters data={data} update={update} setPrintContent={setPrintContent} />}
|
||
{view === "settings" && <Settings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom} setUiZoom={setUiZoom} />}
|
||
{view === "studio-budget" && <StudioBudget data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
|
||
</Suspense>
|
||
</div>
|
||
|
||
<UpdateNotifier />
|
||
|
||
{showChangelog && (() => {
|
||
const CHANGELOGS = {
|
||
"0.8.1": {
|
||
items: [
|
||
["Update-Fix", "Behebt einen Fehler beim Upgrade von 0.7 auf 0.8: Lokal-Installationen wurden ungewollt in den Cloud-Modus geschoben und der Cloud-Setup-Wizard angezeigt, obwohl bereits lokale Daten vorhanden waren. Die App prüft jetzt vor einem automatischen Modus-Wechsel, ob lokale Daten existieren — und in Tauri-Installationen wird der Modus nie implizit gesetzt."],
|
||
],
|
||
},
|
||
"0.8": {
|
||
items: [
|
||
["Cloud-Variante", "Rapport kann jetzt nicht nur lokal, sondern auch auf einem eigenen Server (Supabase) laufen. Beim ersten Öffnen wählt man «Lokal» oder «Cloud»; beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User und Live-Sync zwischen Geräten."],
|
||
["Erst-Einrichtung als Wizard", "Cloud-Einrichtung als 3-Schritt-Assistent: Studio-Stammdaten, Admin-Account, optionale Buchhaltungsdaten (IBAN, MwSt, Stundensatz). Adresse und Bankverbindung sind optional und können später ergänzt werden."],
|
||
["Multi-Studio", "Ein Account kann mehrere Studios verwalten. Beim Anlegen eines neuen Studios lassen sich Personen (Kunden & Partner) aus bestehenden Studios übernehmen — Änderungen sind dann für alle verlinkten Studios sichtbar."],
|
||
["Live-Sync zwischen Browsern", "Änderungen in einem Browser (z.B. neue Pinnwand-Notiz) erscheinen in allen anderen offenen Rapport-Tabs ohne Reload. Funktioniert über Postgres-Realtime."],
|
||
["Mitarbeiter einladen", "Admins können Mitarbeiter direkt in den Einstellungen einladen: Email + Anzeigename + App-Rolle + temporäres Passwort. Eingeladene erhalten Zugangsdaten zum sofortigen Login."],
|
||
["Passwort-Reset", "Im Cloud-Modus gibt es einen «Passwort vergessen?»-Link auf der Anmeldeseite. Mit der hinterlegten Email wird ein Reset-Link per Mail verschickt."],
|
||
["Web-Version", "Wer keine Desktop-App installieren möchte, kann Rapport im Browser unter der Studio-Adresse nutzen (z.B. app.rapport.kgva.ch). Identische UI, gleiches Backend, kein Tauri nötig."],
|
||
["Sichere Datenhaltung pro Studio", "Daten verschiedener Studios sind auf Datenbank-Ebene strikt getrennt (Row-Level-Security). Kein Studio sieht je die Daten eines anderen."],
|
||
["Persönliche Zugangsdaten via Email", "Cloud-Anmeldung mit Email + Passwort (statt Benutzername). Lokal-Modus weiter wie gehabt mit Benutzername + Passwort."],
|
||
],
|
||
},
|
||
"0.7": {
|
||
items: [
|
||
["Automatische Updates", "Rapport prüft beim Start, ob eine neue Version unter git.kgva.ch verfügbar ist, und installiert sie auf Knopfdruck — kein manuelles DMG-Download mehr nötig. Updates lassen sich überspringen oder verschieben; Pakete werden vor der Installation per Signaturprüfung verifiziert."],
|
||
["System-Tray-Icon", "Rapport läuft im Hintergrund weiter, wenn das Fenster geschlossen wird, und ist über ein Menüleisten-Icon erreichbar. Schnellzugriff auf Dashboard, Zeiterfassung, Projekte und Buchhaltung; Cmd+Q beendet die App vollständig."],
|
||
["Einstellungen: Updates & Support", "Neuer Tab «Updates & Support» mit manueller Update-Suche, Zeitstempel der letzten Prüfung und Link zur Dokumentation auf rapport.kgva.ch."],
|
||
],
|
||
},
|
||
"0.6": {
|
||
items: [
|
||
["Sicherheit: Passwort-Hashing", "Passwörter werden jetzt mit PBKDF2 (SHA-256, 100 000 Iterationen) und einem zufälligen Salt gespeichert. Bestehende Klartext-Passwörter werden beim ersten erfolgreichen Login transparent migriert."],
|
||
["Login: Brute-Force-Schutz", "Nach 5 Fehlversuchen wird der Login für 60 Sekunden gesperrt — mit sichtbarem Countdown. Passwortvergleich erfolgt in konstanter Zeit, Mindestlänge bei der Einrichtung auf 8 Zeichen erhöht."],
|
||
["Briefe: HTML-Sanitizer", "Brieftexte werden vor dem Druck durch einen Allowlist-Sanitizer geleitet. Script-Tags, javascript:-URLs und on*-Handler werden entfernt — externe Links bekommen automatisch rel=\"noopener noreferrer\"."],
|
||
["Datenexport ohne Klartext-Passwörter", "Beim Sichern der Daten werden Legacy-Klartext-Passwörter entfernt; nur die nicht umkehrbaren PBKDF2-Hashes bleiben im Backup."],
|
||
["Belege direkt in der Zeiterfassung", "Spesenbelege (Bild oder PDF) können jetzt direkt aus der Tagesansicht hochgeladen und angezeigt werden. Bilder werden vor dem Speichern auf 1600 px skaliert und JPEG-komprimiert."],
|
||
["Avatar-Upload schlanker", "Profilbilder werden auf 256 px skaliert und als JPEG gespeichert — localStorage bleibt klein. Nur Bilddateien werden akzeptiert."],
|
||
["Schnellerer Start", "Module werden per Code-Splitting nur bei Bedarf geladen — die App startet spürbar schneller und braucht weniger Speicher."],
|
||
["QR-Rechnung offline", "Die swissqrbill-Bibliothek ist jetzt lokal eingebunden — QR-Einzahlungsscheine funktionieren ohne Internet-Verbindung."],
|
||
["Stabilere IDs", "Neue Datensätze nutzen kryptografisch zufällige IDs (crypto.randomUUID) statt Math.random."],
|
||
["Über Rapport & UI-Feinschliff", "Neues «Über Rapport»-Modal mit Lizenzinfo, einheitliche Stift-Icons zum Bearbeiten, Pinnwand-Kategorien als Pills."],
|
||
],
|
||
},
|
||
"0.5": {
|
||
items: [
|
||
["Anmeldesystem", "Benutzerverwaltung mit Rollen und Passwörtern. Jeder Mitarbeiter erhält einen eigenen Login. Berechtigungen steuern, welche Module sichtbar sind."],
|
||
["Migration bestehender Daten", "Beim ersten Start mit einer bestehenden Datenbank erscheint ein Migrations-Assistent: Daten sichern, Admin-Konto einrichten — alle bisherigen Inhalte bleiben erhalten."],
|
||
["Rechnungstypen: Akonto / Teilrechnung / Schluss", "Klare Trennung der Rechnungsarten mit unterschiedlicher steuerlicher Behandlung. Akonto ist erst bei der Schlussrechnung steuerrelevant; Teilrechnungen sind sofort wirksam."],
|
||
["Neuer Rechnungsdialog", "Zweistufige Auswahl: zuerst Art der Rechnung (Akonto / Teilrechnung / Schlussrechnung), dann Berechnungsmethode (Stunden / % vom Budget / Fixer Betrag / SIA-Phase)."],
|
||
["Akonto & Teilrechnung nach SIA-Phase", "Für Pauschal-Projekte können einzelne SIA-Phasen direkt verrechnet werden — bereits verrechnete Phasen werden als solche markiert."],
|
||
["Aufwandsrechnungen erweitert", "Stundenprojekte unterstützen jetzt Akonto (Stunden bis heute, %, Fixbetrag) und Teilrechnung (Stunden auswählen, %, Fixbetrag, SIA-Phase)."],
|
||
["Buchhaltung: Akonto-MwSt getrennt", "Akonto-Rechnungen werden in der Buchhaltung separat ausgewiesen — die MwSt wird erst bei der Schlussrechnung als steuerrelevant gezählt."],
|
||
["Mitarbeiter: Intern & Absenzen", "Umbenennung zu «Intern / Absenzen». Neue Jahresübersicht mit Monatsvergleich und Vorjahr, Absenzkategorien-Matrix, sowie Auswertung interner Stunden ohne Projektbezug."],
|
||
],
|
||
},
|
||
"0.4": {
|
||
items: [
|
||
["Material Design 3", "Sidebar mit einklappbarem Icon-Modus und Material Symbols Rounded Icons. Buttons als Pill, Inputs und Cards mit mehr Radius, Tags als Chips, Modals mit Backdrop-Blur."],
|
||
["Interne Projektbeteiligung", "Mitarbeitende können direkt im Projekt zugewiesen werden. In Protokollen erscheinen unter «Intern» nur noch die zugewiesenen Personen."],
|
||
["Offerten im Budget", "Neben Rechnungen können jetzt auch Offerten in die Einnahmen-Planung des Bürobudgets einbezogen werden."],
|
||
["Kategorien direkt auf der Seite", "Spesenarten und Ausgaben-Kategorien werden neu direkt auf der jeweiligen Seite verwaltet, nicht mehr in den Einstellungen."],
|
||
],
|
||
},
|
||
"0.3": {
|
||
items: [
|
||
["Visuelles Redesign", "Sidebar schwebt als eigenständiges Panel mit Radius und Schatten. Cards haben mehr Tiefe. Header kompakter, Abstände überarbeitet, Menüpunkte in Kapitälchen. Studio-Name in der Sidebar."],
|
||
["Zoom", "Der UI-Zoom betrifft nur den Hauptinhalt — die Sidebar bleibt immer gleich gross und unverzerrt."],
|
||
],
|
||
},
|
||
"0.2": {
|
||
items: [
|
||
["Neue Module", "Lohnabrechnung, Bürobudget, Protokolle, Lieferscheine und Spesen (Mitarbeiter & intern) hinzugefügt."],
|
||
["Zeiterfassung Wochenansicht", "Visuelles Zeitraster mit Drag & Drop — Einträge verschieben und skalieren, Wechsel zwischen Tag-, Wochen- und Monatsansicht."],
|
||
["Ferienplanung", "Ferienanträge stellen, genehmigen und zurückziehen. Absenzverwaltung ausgebaut."],
|
||
["Teilzeit", "Lohn wird proportional zum Pensum berechnet, pro Monat überschreibbar."],
|
||
],
|
||
},
|
||
"0.1": {
|
||
items: [
|
||
["Erster Release", "Projekte, Kunden, Mitarbeiterverwaltung, Zeiterfassung."],
|
||
["Rechnungen & Offerten", "Rechnungen mit QR-Einzahlungsschein, Offerten nach SIA 102, manuell oder frei — konvertierbar zur Rechnung."],
|
||
["Einstellungen", "Studio-Stammdaten, Stundensätze, Rollen, MwSt und Projektnummern-Format."],
|
||
],
|
||
},
|
||
};
|
||
const versions = Object.keys(CHANGELOGS);
|
||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.1"];
|
||
return (
|
||
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
|
||
<div style={{ background: "#1a1a18", padding: "28px 32px 20px" }}>
|
||
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>CHANGELOG</div>
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 16 }}>
|
||
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Alpha {changelogVersion}</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 6, marginTop: 14 }}>
|
||
{versions.map(v => (
|
||
<button key={v} onClick={() => setChangelogVersion(v)} style={{ fontSize: 10, padding: "3px 10px", borderRadius: 20, border: "1px solid", borderColor: v === changelogVersion ? "#b07848" : "#3a3a30", background: v === changelogVersion ? "#b07848" : "transparent", color: v === changelogVersion ? "#1a1a18" : "#888", cursor: "pointer", fontFamily: "inherit", letterSpacing: "0.06em" }}>
|
||
Alpha {v}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: "20px 32px 8px", maxHeight: 340, overflowY: "auto" }}>
|
||
{current.items.map(([title, desc]) => (
|
||
<div key={title} style={{ display: "flex", gap: 14, marginBottom: 14 }}>
|
||
<div style={{ width: 4, flexShrink: 0, background: "#b07848", borderRadius: 2, marginTop: 2 }} />
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>{title}</div>
|
||
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>{desc}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ padding: "12px 32px 24px" }}>
|
||
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8.1"); }}>
|
||
Schliessen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{showAbout && (
|
||
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
|
||
<div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}>
|
||
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>ÜBER RAPPORT</div>
|
||
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Rapport</div>
|
||
<div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8.1 · Studio-Management für Architekturbüros</div>
|
||
</div>
|
||
<div style={{ padding: "20px 32px 8px" }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div>
|
||
<div style={{ display: "flex", gap: 14, marginBottom: 14 }}>
|
||
<div style={{ width: 4, flexShrink: 0, background: "#b07848", borderRadius: 2 }} />
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>GNU AGPL-3.0</div>
|
||
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>Rapport ist freie Software. Der Quellcode darf eingesehen, verändert und weitergegeben werden — unter den Bedingungen der AGPL-3.0-Lizenz.</div>
|
||
<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noreferrer" style={{ fontSize: 11, color: "#b07848", textDecoration: "none", display: "inline-block", marginTop: 4 }}>www.gnu.org/licenses/agpl-3.0 ↗</a>
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12, marginTop: 20 }}>TECHNOLOGIEN</div>
|
||
{[
|
||
["React 19", "UI-Framework"],
|
||
["Vite", "Build-Tool"],
|
||
["Tauri", "Desktop-App-Rahmen"],
|
||
["Material Symbols", "Icons von Google"],
|
||
].map(([name, desc]) => (
|
||
<div key={name} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: "1px solid #f0ede8" }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: "#1a1a18" }}>{name}</div>
|
||
<div style={{ fontSize: 12, color: "#aaa" }}>{desc}</div>
|
||
</div>
|
||
))}
|
||
<div style={{ marginTop: 20, padding: "12px 14px", background: "#f7f5f2", borderRadius: 8 }}>
|
||
<div style={{ fontSize: 11, color: "#888", lineHeight: 1.6 }}>
|
||
Entwickelt von <span style={{ color: "#1a1a18", fontWeight: 600 }}>Gabriele Varano</span><br />
|
||
<a href="https://rapport.gabrielevarano.ch/" target="_blank" rel="noreferrer" style={{ color: "#b07848", textDecoration: "none" }}>rapport.gabrielevarano.ch ↗</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: "16px 32px 24px" }}>
|
||
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => setShowAbout(false)}>Schliessen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|