"use client"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; import EditRoundedIcon from "@mui/icons-material/EditRounded"; import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded"; import WalletRoundedIcon from "@mui/icons-material/WalletRounded"; import { Alert, Box, Button, Card, CardContent, Chip, Container, MenuItem, Stack, Tab, Tabs, TextField, Typography, useMediaQuery } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import { signOut } from "next-auth/react"; import { useRouter } from "next/navigation"; import type { FormEvent } from "react"; import { startTransition, useEffect, useMemo, useState } from "react"; import { BudgetColumn } from "@/components/dashboard/budget-column"; import { ColorPickerField } from "@/components/dashboard/color-picker-field"; import type { DashboardAccountingPeriod, DashboardAuditLog, DashboardManagedUser, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; import { APPROVAL_FLOW, approvalLabel, canManageBudgets, canManageUsers, getDefaultApprovalPermissionsForRole, roleLabel } from "@/lib/domain"; type DashboardShellProps = { viewer: DashboardViewer; workingGroups: DashboardWorkingGroup[]; managedUsers: DashboardManagedUser[]; auditLogs: DashboardAuditLog[]; accountingPeriods: DashboardAccountingPeriod[]; currentPeriodId: string; approvalThreshold: number; }; type ExpenseFormState = { title: string; description: string; amount: string; agId: string; budgetId: string; recurrence: "NONE" | "MONTHLY"; proofUrl: string; }; type BudgetFormState = { workingGroupId: string; name: string; totalBudget: string; colorCode: string; }; type WorkingGroupFormState = { name: string; }; type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number]; type UserFormState = { username: string; password: string; role: "ADMIN" | "FINANCE" | "MEMBER"; workingGroupId: string; approvalPermissions: ApprovalPermissionValue[]; }; type ManagedUserDraft = { role: "ADMIN" | "FINANCE" | "MEMBER"; workingGroupId: string; approvalPermissions: ApprovalPermissionValue[]; }; type PeriodFormState = { name: string; startsAt: string; endsAt: string; copyBudgetsFromPeriodId: string; }; type DashboardMessage = { type: "success" | "error"; text: string; }; function sortApprovalPermissions(value: ApprovalPermissionValue[]) { return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType)); } function toggleApprovalPermission( currentValue: ApprovalPermissionValue[], approvalType: ApprovalPermissionValue ) { return currentValue.includes(approvalType) ? currentValue.filter((entry) => entry !== approvalType) : sortApprovalPermissions([...currentValue, approvalType]); } type MobileSection = "overview" | "actions"; type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs"; const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }); const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); function toDateInputValue(value: string) { return value.slice(0, 10); } function formatPeriodRange(startsAt: string, endsAt: string) { const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`; } function getSuggestedPeriodDraft(currentPeriod: DashboardAccountingPeriod | undefined): PeriodFormState { if (!currentPeriod) { const year = new Date().getFullYear(); return { name: `Haushalt ${year}`, startsAt: toDateInputValue(new Date(Date.UTC(year, 0, 1)).toISOString()), endsAt: toDateInputValue(new Date(Date.UTC(year, 11, 31)).toISOString()), copyBudgetsFromPeriodId: "" }; } const startsAt = new Date(currentPeriod.startsAt); const endsAt = new Date(currentPeriod.endsAt); const duration = Math.max(endsAt.getTime() - startsAt.getTime(), 24 * 60 * 60 * 1000); const nextStart = new Date(endsAt.getTime() + 24 * 60 * 60 * 1000); const nextEnd = new Date(nextStart.getTime() + duration); return { name: `${currentPeriod.name} Folgezeitraum`, startsAt: toDateInputValue(nextStart.toISOString()), endsAt: toDateInputValue(nextEnd.toISOString()), copyBudgetsFromPeriodId: currentPeriod.id }; } function generatePassword(length = 14) { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%"; const cryptoSource = globalThis.crypto; if (!cryptoSource?.getRandomValues) { return "RFP2026!Start"; } const values = cryptoSource.getRandomValues(new Uint32Array(length)); return Array.from(values, (value) => alphabet[value % alphabet.length]).join(""); } async function parseResponse(response: Response) { const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden."); } return payload; } export function DashboardShell({ viewer, workingGroups, managedUsers, auditLogs, accountingPeriods, currentPeriodId, approvalThreshold }: DashboardShellProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg")); const router = useRouter(); const visibleGroups = workingGroups; const editableExpenseGroups = viewer.role === "MEMBER" ? workingGroups.filter((group) => group.id === viewer.workingGroupId) : workingGroups; const canManageAccounts = canManageUsers(viewer.role); const canManagePeriods = canManageBudgets(viewer.role); const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0]; const desktopSections = [ { value: "overview" as const, label: "\u00dcbersicht" }, ...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []), ...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []), ...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []), ...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : []) ]; const showDesktopSectionTabs = !isCompactLayout && desktopSections.length > 1; const defaultEditableGroup = editableExpenseGroups.find((group) => group.id === viewer.workingGroupId) ?? editableExpenseGroups[0] ?? visibleGroups[0]; const defaultBudget = defaultEditableGroup?.budgets[0]; const [expenseForm, setExpenseForm] = useState({ title: "", description: "", amount: "", agId: defaultEditableGroup?.id ?? "", budgetId: defaultBudget?.id ?? "", recurrence: "NONE", proofUrl: "" }); const [budgetForm, setBudgetForm] = useState({ workingGroupId: visibleGroups[0]?.id ?? "", name: "Hauptbudget", totalBudget: "1200", colorCode: "#FFB94A" }); const [workingGroupForm, setWorkingGroupForm] = useState({ name: "" }); const [userForm, setUserForm] = useState({ username: "", password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "", approvalPermissions: [] }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); const [mobileSection, setMobileSection] = useState("overview"); const [desktopSection, setDesktopSection] = useState("overview"); const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); const [selectedMobileGroupId, setSelectedMobileGroupId] = useState( viewer.workingGroupId ?? visibleGroups[0]?.id ?? "" ); const [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null); const [passwordDrafts, setPasswordDrafts] = useState>({}); const [userDrafts, setUserDrafts] = useState>({}); const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); useEffect(() => { if (visibleGroups.length === 0) { setSelectedMobileGroupId(""); return; } const hasSelectedGroup = visibleGroups.some((group) => group.id === selectedMobileGroupId); if (!hasSelectedGroup) { setSelectedMobileGroupId(viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""); } }, [selectedMobileGroupId, viewer.workingGroupId, visibleGroups]); useEffect(() => { setSelectedCurrentPeriodId(currentPeriodId); setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); }, [currentPeriod, currentPeriodId]); useEffect(() => { if (!desktopSections.some((section) => section.value === desktopSection)) { setDesktopSection("overview"); } }, [desktopSection, desktopSections]); useEffect(() => { if (visibleGroups.length === 0) { setBudgetForm((current) => ({ ...current, workingGroupId: "" })); return; } const groupStillExists = visibleGroups.some((group) => group.id === budgetForm.workingGroupId); if (!groupStillExists) { setBudgetForm((current) => ({ ...current, workingGroupId: visibleGroups[0]?.id ?? "" })); } }, [budgetForm.workingGroupId, visibleGroups]); useEffect(() => { if (!message || message.type !== "success") { return; } const timeoutId = window.setTimeout(() => { setMessage((current) => (current?.type === "success" ? null : current)); }, 10000); return () => { window.clearTimeout(timeoutId); }; }, [message]); useEffect(() => { const hasEditableGroup = editableExpenseGroups.some((group) => group.id === expenseForm.agId); if (!hasEditableGroup) { setExpenseForm((current) => ({ ...current, agId: defaultEditableGroup?.id ?? "", budgetId: defaultEditableGroup?.budgets[0]?.id ?? "" })); } }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId]); useEffect(() => { const selectedGroup = editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; const hasBudget = selectedGroup?.budgets.some((budget) => budget.id === expenseForm.budgetId) ?? false; if (!hasBudget) { setExpenseForm((current) => ({ ...current, budgetId: selectedGroup?.budgets[0]?.id ?? "" })); } }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]); useEffect(() => { const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId); if (!groupStillExists) { setUserForm((current) => ({ ...current, workingGroupId: visibleGroups[0]?.id ?? "" })); } }, [userForm.workingGroupId, visibleGroups]); useEffect(() => { setApprovalThresholdDraft(approvalThreshold.toFixed(2)); }, [approvalThreshold]); useEffect(() => { if (editingUserId && !managedUsers.some((user) => user.id === editingUserId)) { setEditingUserId(null); } }, [editingUserId, managedUsers]); const selectedExpenseGroup = editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0]; const selectedBudgetWorkingGroup = visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null; const selectedPeriodForManagement = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft { return userDrafts[user.id] ?? { role: user.role, workingGroupId: user.workingGroupId ?? "", approvalPermissions: sortApprovalPermissions(user.approvalPermissions) }; } function updateManagedUserDraft(user: DashboardManagedUser, patch: Partial) { setUserDrafts((current) => ({ ...current, [user.id]: { ...getManagedUserDraft(user), ...patch } })); } function resetManagedUserDraft(user: DashboardManagedUser) { setUserDrafts((current) => ({ ...current, [user.id]: { role: user.role, workingGroupId: user.workingGroupId ?? "", approvalPermissions: sortApprovalPermissions(user.approvalPermissions) } })); } const totals = useMemo(() => { return visibleGroups.reduce( (summary, group) => { const approved = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0), 0 ), 0 ); const pending = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), 0 ), 0 ); const subscriptions = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.reduce( (sum, expense) => sum + (expense.recurrence === "MONTHLY" ? expense.amount : 0), 0 ), 0 ); const subscriptionCount = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.filter((expense) => expense.recurrence === "MONTHLY").length, 0 ); summary.budget += group.totalBudget; summary.approved += approved; summary.pending += pending; summary.subscriptions += subscriptions; summary.subscriptionCount += subscriptionCount; return summary; }, { budget: 0, approved: 0, pending: 0, subscriptions: 0, subscriptionCount: 0 } ); }, [visibleGroups]); async function runAction( task: () => Promise, successMessage: string | ((result: T) => string) ) { setBusy(true); setMessage(null); try { const result = await task(); setMessage({ type: "success", text: typeof successMessage === "function" ? successMessage(result) : successMessage }); startTransition(() => { router.refresh(); }); } catch (error) { const text = error instanceof Error ? error.message : "Unerwarteter Fehler."; setMessage({ type: "error", text }); } finally { setBusy(false); } } async function handleCreateExpense(event: FormEvent) { event.preventDefault(); if (!expenseForm.agId) { setMessage({ type: "error", text: "Bitte zuerst eine bearbeitbare AG ausw\u00e4hlen." }); return; } if (!expenseForm.budgetId) { setMessage({ type: "error", text: "Bitte zuerst ein Budget f\u00fcr diese AG anlegen oder ausw\u00e4hlen." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/expenses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: expenseForm.title, description: expenseForm.description, amount: expenseForm.amount, agId: expenseForm.agId, budgetId: expenseForm.budgetId, recurrence: expenseForm.recurrence, proofUrl: expenseForm.proofUrl }) }) ); const resetGroup = defaultEditableGroup?.id ?? ""; const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; setExpenseForm({ title: "", description: "", amount: "", agId: resetGroup, budgetId: resetBudget, recurrence: "NONE", proofUrl: "" }); }, "Ausgabe wurde gespeichert."); } async function handleUpsertBudget(event: FormEvent) { event.preventDefault(); if (!budgetForm.workingGroupId) { setMessage({ type: "error", text: "Bitte zuerst eine AG auswählen oder neu anlegen." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/budgets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...budgetForm, periodId: currentPeriodId }) }) ); }, "Budget wurde gespeichert."); } async function handleCreateWorkingGroup(event: FormEvent) { event.preventDefault(); await runAction( async () => { const result = (await parseResponse( await fetch("/api/working-groups", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(workingGroupForm) }) )) as { workingGroup?: { id: string; name: string } }; setWorkingGroupForm({ name: "" }); if (result.workingGroup?.id) { setBudgetForm((current) => ({ ...current, workingGroupId: result.workingGroup?.id ?? current.workingGroupId })); } return result; }, (result) => `AG ${result.workingGroup?.name ?? "wurde"} wurde angelegt.` ); } async function handleApprove(expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") { await runAction(async () => { await parseResponse( await fetch(`/api/expenses/${expenseId}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approvalType }) }) ); }, `Freigabe ${approvalType} wurde erfasst.`); } async function handleMarkPaid(expenseId: string) { await runAction(async () => { await parseResponse( await fetch(`/api/expenses/${expenseId}/paid`, { method: "POST" }) ); }, "Ausgabe ist jetzt als bezahlt markiert."); } async function handleDocument(expenseId: string, proofUrl?: string) { await runAction(async () => { await parseResponse( await fetch(`/api/expenses/${expenseId}/documented`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proofUrl }) }) ); }, "Ausgabe wurde dokumentiert."); } async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) { await runAction(async () => { await parseResponse( await fetch(`/api/budgets/${budgetId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, totalBudget, colorCode }) }) ); }, "Budget wurde aktualisiert."); } async function handleDeleteBudget(budgetId: string) { await runAction(async () => { await parseResponse( await fetch(`/api/budgets/${budgetId}`, { method: "DELETE" }) ); }, "Budget wurde gel\u00f6scht."); } async function handleDeleteExpense(expenseId: string) { await runAction(async () => { await parseResponse( await fetch(`/api/expenses/${expenseId}`, { method: "DELETE" }) ); }, "Ausgabe wurde gel\u00f6scht."); } async function handleCreatePeriod(event: FormEvent) { event.preventDefault(); await runAction(async () => { await parseResponse( await fetch("/api/periods", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(periodForm) }) ); }, "Neuer Abrechnungszeitraum wurde angelegt."); } async function handleDeletePeriod(periodId: string, periodName: string) { await runAction(async () => { await parseResponse( await fetch(`/api/periods/${periodId}`, { method: "DELETE" }) ); }, `Zeitraum ${periodName} wurde gelöscht.`); } async function handleSetCurrentPeriod() { await runAction(async () => { await parseResponse( await fetch("/api/periods/current", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ periodId: selectedCurrentPeriodId }) }) ); }, "Aktuelle \u00dcbersicht wurde auf den gew\u00e4hlten Zeitraum umgestellt."); } async function handleSaveWorkingGroup(groupId: string, name: string) { await runAction(async () => { await parseResponse( await fetch(`/api/working-groups/${groupId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }) ); }, "AG wurde aktualisiert."); } async function handleDeleteWorkingGroup(groupId: string, groupName: string) { await runAction(async () => { await parseResponse( await fetch(`/api/working-groups/${groupId}`, { method: "DELETE" }) ); const nextGroup = visibleGroups.find((group) => group.id !== groupId) ?? null; setBudgetForm((current) => ({ ...current, workingGroupId: nextGroup?.id ?? "" })); setExpenseForm((current) => ({ ...current, agId: current.agId === groupId ? nextGroup?.id ?? "" : current.agId, budgetId: current.agId === groupId ? nextGroup?.budgets[0]?.id ?? "" : current.budgetId })); }, `AG ${groupName} wurde gelöscht.`); } async function handleCreateUser(event: FormEvent) { event.preventDefault(); await runAction( async () => { const createdPassword = userForm.password; const createdUsername = userForm.username.trim().toLowerCase(); await parseResponse( await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: createdUsername, password: userForm.password, role: userForm.role, workingGroupId: userForm.workingGroupId, approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions) }) }) ); setUserForm({ username: "", password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "", approvalPermissions: [] }); return { createdUsername, createdPassword }; }, ({ createdUsername, createdPassword }) => `Nutzer wurde angelegt. Startpasswort für ${createdUsername}: ${createdPassword}` ); } async function handleUpdateUser(user: DashboardManagedUser) { const draft = getManagedUserDraft(user); await runAction(async () => { await parseResponse( await fetch(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: draft.role, workingGroupId: draft.workingGroupId, approvalPermissions: sortApprovalPermissions(draft.approvalPermissions) }) }) ); setEditingUserId(null); }, `Nutzer ${user.username} wurde aktualisiert.`); } async function handleSaveApprovalThreshold() { const nextThreshold = Number(approvalThresholdDraft.replace(",", ".")); if (!Number.isFinite(nextThreshold) || nextThreshold < 0) { setMessage({ type: "error", text: "Bitte eine gueltige Freigabe-Schwelle eingeben." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approvalThreshold: nextThreshold }) }) ); }, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`); } async function handleDeleteUser(userId: string) { await runAction(async () => { await parseResponse( await fetch(`/api/users/${userId}`, { method: "DELETE" }) ); }, "Nutzer wurde gel\u00f6scht."); } async function handleResetPassword(userId: string, userName: string) { const nextPassword = passwordDrafts[userId]?.trim() ?? ""; if (nextPassword.length < 8) { setMessage({ type: "error", text: "Bitte ein neues Passwort mit mindestens 8 Zeichen eingeben." }); return; } await runAction( async () => { await parseResponse( await fetch(`/api/users/${userId}/password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: nextPassword }) }) ); setEditingPasswordUserId(null); return { userName, nextPassword }; }, ({ userName: changedUserName, nextPassword: changedPassword }) => `Neues Passwort f\u00fcr ${changedUserName}: ${changedPassword}` ); } async function handleImportBackup() { if (!backupFile) { setMessage({ type: "error", text: "Bitte zuerst eine CSV-Datei auswählen." }); return; } await runAction( async () => { const formData = new FormData(); formData.set("file", backupFile); const result = await parseResponse( await fetch("/api/import/csv", { method: "POST", body: formData }) ); setBackupFile(null); return result as { importedRows?: number }; }, (result) => `Backup wurde eingespielt.${typeof result.importedRows === "number" ? ` Importierte Zeilen: ${result.importedRows}.` : ""}` ); } async function handleRestoreAuditLog(entryId: string, summary: string) { if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) { return; } await runAction(async () => { await parseResponse( await fetch(`/api/audit-logs/${entryId}/restore`, { method: "POST" }) ); }, "Änderung wurde zurückgesetzt."); } function openPasswordReset(userId: string) { setEditingPasswordUserId(userId); setPasswordDrafts((current) => ({ ...current, [userId]: current[userId] && current[userId].length >= 8 ? current[userId] : generatePassword() })); } function openUserEditor(user: DashboardManagedUser) { resetManagedUserDraft(user); setEditingUserId(user.id); } function renderApprovalPermissionSelector( value: ApprovalPermissionValue[], onToggle: (approvalType: ApprovalPermissionValue) => void, helperText: string ) { return ( Freigaberollen {APPROVAL_FLOW.map((approvalType) => { const selected = value.includes(approvalType); return ( ); })} {helperText} ); } const islandCardSx = { borderRadius: { xs: "24px", md: "30px" }, border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.82 : 0.96)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.04 : 0.02)} 100%)`, overflow: "hidden", boxShadow: isDark ? `0 20px 48px ${alpha("#02040A", 0.34)}` : `0 18px 44px ${alpha("#B86E2B", 0.08)}` }; const nestedPanelSx = { p: 2, borderRadius: "20px", border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.78 : 0.9)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.05 : 0.02)} 100%)`, overflow: "hidden" }; const periodManagementPanel = canManagePeriods ? ( Zeitraum wechseln {"Nur Vorstand und Finanz-AG können die aktuelle Übersicht global umstellen."} setSelectedCurrentPeriodId(event.target.value)} fullWidth InputLabelProps={{ shrink: true }} sx={{ minWidth: 0 }} > {accountingPeriods.map((period) => ( {period.name} ))} {selectedPeriodForManagement?.isCurrent ? "Der aktuell aktive Zeitraum kann nicht gelöscht werden." : "Leere, nicht aktive Zeiträume lassen sich hier wieder entfernen."} Neuen Zeitraum anlegen setPeriodForm((current) => ({ ...current, name: event.target.value }))} required fullWidth /> setPeriodForm((current) => ({ ...current, startsAt: event.target.value }))} InputLabelProps={{ shrink: true }} required fullWidth /> setPeriodForm((current) => ({ ...current, endsAt: event.target.value }))} InputLabelProps={{ shrink: true }} required fullWidth /> setPeriodForm((current) => ({ ...current, copyBudgetsFromPeriodId: event.target.value })) } fullWidth helperText={"Optional kopiert die vorhandenen Budgettöpfe direkt in den neuen Zeitraum."} > Ohne Budgetübernahme {accountingPeriods.map((period) => ( {period.name} ))} ) : null; const actionCards = ( {isCompactLayout || desktopSection === "overview" ? ( Neue Ausgabe {"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "} {approvalThreshold.toFixed(2)} {" EUR wird automatisch freigegeben."} setExpenseForm((current) => ({ ...current, title: event.target.value }))} required fullWidth /> setExpenseForm((current) => ({ ...current, description: event.target.value })) } fullWidth multiline minRows={3} /> setExpenseForm((current) => ({ ...current, amount: event.target.value }))} required fullWidth /> setExpenseForm((current) => ({ ...current, recurrence: event.target.value as ExpenseFormState["recurrence"] })) } fullWidth helperText={"Monatliche Abos erscheinen oben gesammelt im \u00dcberblick."} > Einmalig Monatliches Abo setExpenseForm((current) => ({ ...current, agId: event.target.value }))} required fullWidth disabled={viewer.role === "MEMBER" || editableExpenseGroups.length === 0} helperText={ viewer.role === "MEMBER" ? "Du kannst nur in deiner eigenen AG buchen." : "W\u00e4hle die AG, in der die Ausgabe erfasst werden soll." } > {editableExpenseGroups.map((group) => ( {group.name} ))} setExpenseForm((current) => ({ ...current, budgetId: event.target.value }))} required fullWidth disabled={selectedBudgetOptions.length === 0} helperText={ selectedBudgetOptions.length === 0 ? "In dieser AG gibt es noch kein Budget." : "Bitte den passenden Budgettopf w\u00e4hlen." } > {selectedBudgetOptions.map((budget) => ( {budget.name} ))} setExpenseForm((current) => ({ ...current, proofUrl: event.target.value }))} fullWidth /> ) : null} {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( AG anlegen {"Lege Arbeitsgruppen separat an. Bearbeiten oder löschen geht danach direkt in der Übersicht per Stift."} setWorkingGroupForm((current) => ({ ...current, name: event.target.value })) } required fullWidth /> ) : null} {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( Budget anlegen {"Neue Budgett\u00f6pfe werden immer f\u00fcr den aktuell ausgew\u00e4hlten Abrechnungszeitraum angelegt."} {currentPeriod ? ( ) : null} setBudgetForm((current) => ({ ...current, workingGroupId: event.target.value })) } required fullWidth disabled={visibleGroups.length === 0} helperText={ visibleGroups.length === 0 ? "Lege zuerst eine AG an." : "Das Budget wird in der ausgewaehlten AG angelegt." } > {visibleGroups.map((group) => ( {group.name} ))} {selectedBudgetWorkingGroup ? `Neue Budgettöpfe landen in ${selectedBudgetWorkingGroup.name}.` : "Wähle zuerst eine bestehende AG aus."} setBudgetForm((current) => ({ ...current, name: event.target.value }))} required /> setBudgetForm((current) => ({ ...current, totalBudget: event.target.value })) } required /> setBudgetForm((current) => ({ ...current, colorCode: value }))} /> ) : null} {canManagePeriods && isCompactLayout ? ( {periodManagementPanel} ) : null} {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( CSV-Backup {"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."} {backupFile ? `Ausgewählt: ${backupFile.name}` : "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."} ) : null} {canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( Nutzer anlegen {"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."} setUserForm((current) => ({ ...current, username: event.target.value }))} required /> setUserForm((current) => ({ ...current, password: event.target.value })) } required fullWidth helperText={"Dieses Passwort wird nach dem Anlegen oben als Bestätigung angezeigt."} /> { const nextRole = event.target.value as UserFormState["role"]; setUserForm((current) => ({ ...current, role: nextRole, approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole)) })); }} required > Vorstand Finanz-AG AG-Mitglied setUserForm((current) => ({ ...current, workingGroupId: event.target.value })) } fullWidth disabled={visibleGroups.length === 0} required={userForm.role === "MEMBER"} helperText={ visibleGroups.length === 0 ? "Lege zuerst eine AG an." : userForm.role === "MEMBER" ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." : "Optional: Auch Vorstand und Finanz-AG können einer AG zugeordnet werden." } > {userForm.role !== "MEMBER" ? Ohne AG : null} {visibleGroups.map((group) => ( {group.name} ))} {renderApprovalPermissionSelector( userForm.approvalPermissions, (approvalType) => setUserForm((current) => ({ ...current, approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType) })), "Lege fest, für welche Freigabeschritte dieses Konto zeichnen darf." )} Freigabe-Schwelle {"Ausgaben unter diesem Betrag werden automatisch freigegeben."} setApprovalThresholdDraft(event.target.value)} helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`} fullWidth /> ) : null} {canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( Nutzer verwalten {"Bestehende Passwörter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung, Freigaberollen und Passwörter pflegen."} {managedUsers.map((user) => { const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0; const isResetOpen = editingPasswordUserId === user.id; const isEditingUser = editingUserId === user.id; const draft = getManagedUserDraft(user); return ( {user.username} {roleLabel(user.role)} {user.approvalPermissions.length > 0 ? ( user.approvalPermissions.map((approvalType) => ( )) ) : ( )} {isEditingUser ? ( { const nextRole = event.target.value as ManagedUserDraft["role"]; updateManagedUserDraft(user, { role: nextRole, approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole)) }); }} fullWidth > Vorstand Finanz-AG AG-Mitglied updateManagedUserDraft(user, { workingGroupId: event.target.value })} fullWidth disabled={visibleGroups.length === 0} required={draft.role === "MEMBER"} helperText={ visibleGroups.length === 0 ? "Lege zuerst eine AG an." : draft.role === "MEMBER" ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." : "Optional: Auch Vorstand und Finanz-AG können einer AG zugeordnet werden." } > {draft.role !== "MEMBER" ? Ohne AG : null} {visibleGroups.map((group) => ( {group.name} ))} {renderApprovalPermissionSelector( draft.approvalPermissions, (approvalType) => updateManagedUserDraft(user, { approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType) }), "Lege fest, welche Freigabeschritte dieses Konto autorisieren darf." )} ) : null} {isResetOpen ? ( setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value })) } fullWidth helperText={ "Nur neu gesetzte Passwörter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen." } /> ) : null} ); })} ) : null} {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( {"\u00c4nderungsverlauf"} {"Zeigt die letzten \u00c4nderungen an Nutzern, Ausgaben, Budgets, AGs und Zeitr\u00e4umen."} {auditLogs.length > 0 ? ( {auditLogs.map((entry) => ( {entry.summary} {entry.entityLabel ? : null} {entry.actor ? `${entry.actor.username} - ${roleLabel(entry.actor.role)}` : "Systemeintrag"} {entry.canRestore ? ( ) : null} ))} ) : ( {"Noch keine \u00c4nderungen protokolliert."} )} ) : null} ); const periodOverviewCard = isCompactLayout ? null : showDesktopSectionTabs ? ( setDesktopSection(nextValue)} variant="fullWidth" sx={{ minHeight: 0, ".MuiTabs-indicator": { display: "none" }, ".MuiTab-root": { minHeight: 48, px: 1.5, py: 1, m: 0.35, borderRadius: "16px", textTransform: "none", fontWeight: 700, border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.1)}`, color: theme.palette.text.secondary, backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.42 : 0.9) }, ".Mui-selected": { color: theme.palette.primary.main, borderColor: alpha(theme.palette.primary.main, 0.35), backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.22 : 0.12) } }} > {desktopSections.map((section) => ( ))} ) : null; const overviewContent = ( {visibleGroups.length > 1 ? ( AG auswählen Wähle die AG, die gerade in der Übersicht angezeigt werden soll. setSelectedMobileGroupId(event.target.value)} fullWidth > {visibleGroups.map((group) => ( {group.name} ))} ) : null} {(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => ( ))} ); const desktopSectionContent = desktopSection === "overview" ? ( {actionCards} {overviewContent} ) : desktopSection === "periods" ? ( {canManagePeriods ? ( {periodManagementPanel} ) : null} ) : ( {actionCards} ); return ( Rave for Peace {"RFP Finanz\u00fcbersicht"} {`Aktuelle \u00dcbersicht: ${currentPeriod?.name ?? "Zeitraum fehlt"}. Alle AGs sind sichtbar, Mitglieder bearbeiten nur ihre eigene AG.`} } label={`Freigegeben: ${currencyFormatter.format(totals.approved)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> } label={`Geplant: ${currencyFormatter.format(totals.pending)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> } label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> {currentPeriod ? ( ) : null} {currentPeriod ? ( ) : null} {message ? {message.text} : null} {periodOverviewCard} {isCompactLayout ? ( setMobileSection(nextValue)} variant="fullWidth" > {mobileSection === "overview" ? overviewContent : actionCards} ) : ( {desktopSectionContent} )} ); }