"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 NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded"; import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded"; import WalletRoundedIcon from "@mui/icons-material/WalletRounded"; import { Alert, Box, Button, Card, CardContent, Checkbox, Chip, Container, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, 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, DashboardSettings, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; import { APPROVAL_FLOW, approvalLabel, canManageBudgets, canManageUsers, getAvailableApprovalRoles, roleLabel } from "@/lib/domain"; type DashboardShellProps = { viewer: DashboardViewer; workingGroups: DashboardWorkingGroup[]; managedUsers: DashboardManagedUser[]; auditLogs: DashboardAuditLog[]; accountingPeriods: DashboardAccountingPeriod[]; currentPeriodId: string; settings: DashboardSettings; }; type ExpenseFormState = { title: string; description: string; amount: string; agId: string; budgetId: string; recurrence: "NONE" | "MONTHLY"; recurrenceStartAt: string; }; type BudgetFormState = { workingGroupId: string; name: string; totalBudget: string; colorCode: string; }; type BudgetReleaseFormState = { workingGroupId: string; budgetId: string; releasedAmount: string; }; type WorkingGroupFormState = { name: string; }; type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number]; type UserFormState = { username: string; password: string; role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string; }; type ManagedUserDraft = { role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string; }; type PeriodFormState = { name: string; startsAt: string; endsAt: string; copyBudgetsFromPeriodId: string; }; type PeriodEditFormState = { name: string; startsAt: string; endsAt: string; }; type OrgaSettingsDraft = { requiredApprovalTypes: ApprovalPermissionValue[]; budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY"; }; 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]); } function sortManagedUsersList(users: DashboardManagedUser[]) { const roleOrder: Record = { BOARD: 0, ORGA: 1, FINANCE: 2, MEMBER: 3 }; return [...users].sort((left, right) => { const roleDifference = roleOrder[left.role] - roleOrder[right.role]; if (roleDifference !== 0) { return roleDifference; } return left.username.localeCompare(right.username, "de-DE"); }); } 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 getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined): PeriodEditFormState { if (!period) { return { name: "", startsAt: "", endsAt: "" }; } return { name: period.name, startsAt: toDateInputValue(period.startsAt), endsAt: toDateInputValue(period.endsAt) }; } 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; } function urlBase64ToUint8Array(value: string) { const padding = "=".repeat((4 - (value.length % 4)) % 4); const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); const rawData = window.atob(base64); return Uint8Array.from([...rawData], (character) => character.charCodeAt(0)); } export function DashboardShell({ viewer, workingGroups, managedUsers, auditLogs, accountingPeriods, currentPeriodId, settings }: 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 approvalThreshold = settings.approvalThreshold; 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", recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) }); const [budgetForm, setBudgetForm] = useState({ workingGroupId: visibleGroups[0]?.id ?? "", name: "Hauptbudget", totalBudget: "1200", colorCode: "#FFB94A" }); const [budgetReleaseForm, setBudgetReleaseForm] = useState({ workingGroupId: visibleGroups[0]?.id ?? "", budgetId: visibleGroups[0]?.budgets[0]?.id ?? "", releasedAmount: (visibleGroups[0]?.budgets[0]?.releasedAmount ?? 0).toFixed(2) }); const [workingGroupForm, setWorkingGroupForm] = useState({ name: "" }); const [userForm, setUserForm] = useState({ username: "", password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "" }); 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 [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null); const [passwordDrafts, setPasswordDrafts] = useState>({}); const [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers)); const [userDrafts, setUserDrafts] = useState>({}); const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false); const [orgaSettingsDraft, setOrgaSettingsDraft] = useState({ requiredApprovalTypes: settings.requiredApprovalTypes, budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget }); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); useEffect(() => { setSelectedCurrentPeriodId(currentPeriodId); setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); }, [currentPeriod, currentPeriodId]); useEffect(() => { const selectedPeriod = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; setPeriodEditForm(getPeriodEditDraft(selectedPeriod)); }, [accountingPeriods, currentPeriod, selectedCurrentPeriodId]); 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 (visibleGroups.length === 0) { setBudgetReleaseForm({ workingGroupId: "", budgetId: "", releasedAmount: "0.00" }); return; } const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0]; const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId) ?? selectedGroup?.budgets[0]; if ( budgetReleaseForm.workingGroupId !== (selectedGroup?.id ?? "") || budgetReleaseForm.budgetId !== (selectedBudget?.id ?? "") ) { setBudgetReleaseForm({ workingGroupId: selectedGroup?.id ?? "", budgetId: selectedBudget?.id ?? "", releasedAmount: (selectedBudget?.releasedAmount ?? 0).toFixed(2) }); } }, [budgetReleaseForm.budgetId, budgetReleaseForm.workingGroupId, visibleGroups]); useEffect(() => { if (!budgetReleaseForm.budgetId) { return; } const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0]; const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId); if (!selectedBudget) { return; } const nextReleasedAmount = selectedBudget.releasedAmount.toFixed(2); if (budgetReleaseForm.releasedAmount !== nextReleasedAmount) { setBudgetReleaseForm((current) => ({ ...current, releasedAmount: nextReleasedAmount })); } }, [budgetReleaseForm.budgetId, budgetReleaseForm.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(() => { setOrgaSettingsDraft({ requiredApprovalTypes: settings.requiredApprovalTypes, budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget }); }, [settings.budgetReleaseNotifyTarget, settings.requiredApprovalTypes]); useEffect(() => { setManagedUsersState(sortManagedUsersList(managedUsers)); }, [managedUsers]); useEffect(() => { if (editingUserId && !managedUsersState.some((user) => user.id === editingUserId)) { setEditingUserId(null); } }, [editingUserId, managedUsersState]); const selectedExpenseGroup = editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; const selectedBudgetWorkingGroup = visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null; const selectedBudgetReleaseGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null; const selectedBudgetReleaseOptions = selectedBudgetReleaseGroup?.budgets ?? []; const selectedBudgetReleaseBudget = selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ?? selectedBudgetReleaseOptions[0] ?? null; const selectedBudgetReleasePaidAmount = selectedBudgetReleaseBudget?.expenses.reduce( (sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0 ) ?? 0; const selectedPeriodForManagement = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; const periodEditDirty = selectedPeriodForManagement !== null && (periodEditForm.name.trim() !== selectedPeriodForManagement.name || periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) || periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt)); function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft { return userDrafts[user.id] ?? { role: user.role, workingGroupId: user.workingGroupId ?? "" }; } 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 ?? "" } })); } 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.periodAmount : 0), 0 ), 0 ); const pending = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 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ür diese AG anlegen oder auswählen." }); return; } if (expenseForm.recurrence === "MONTHLY" && !expenseForm.recurrenceStartAt) { setMessage({ type: "error", text: "Bitte ein Startdatum für das monatliche Abo angeben." }); 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, recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "" }) }) ); const resetGroup = defaultEditableGroup?.id ?? ""; const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; setExpenseForm({ title: "", description: "", amount: "", agId: resetGroup, budgetId: resetBudget, recurrence: "NONE", recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) }); }, "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 handleUploadProof(expenseId: string, file: File, invoiceDate: string) { setBusy(true); setMessage(null); try { const formData = new FormData(); formData.set("file", file); formData.set("invoiceDate", invoiceDate); const result = (await parseResponse( await fetch(`/api/expenses/${expenseId}/proof`, { method: "POST", body: formData }) )) as { document: { proofUrl: string } }; setMessage({ type: "success", text: "Rechnung wurde abgegeben und die Ausgabe ist jetzt bezahlt." }); startTransition(() => { router.refresh(); }); return result.document.proofUrl; } catch (error) { const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden."; setMessage({ type: "error", text }); throw error; } finally { setBusy(false); } } 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 handleSaveBudgetRelease(event: FormEvent) { event.preventDefault(); if (!selectedBudgetReleaseBudget) { setMessage({ type: "error", text: "Bitte zuerst ein Budget ausw\u00e4hlen." }); return; } const nextReleasedAmount = Number(budgetReleaseForm.releasedAmount.replace(",", ".")); if (!Number.isFinite(nextReleasedAmount) || nextReleasedAmount < 0) { setMessage({ type: "error", text: "Bitte einen g\u00fcltigen zus\u00e4tzlichen \u00dcbergabebetrag eingeben." }); return; } await runAction(async () => { await parseResponse( await fetch(`/api/budgets/${selectedBudgetReleaseBudget.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: selectedBudgetReleaseBudget.name, totalBudget: selectedBudgetReleaseBudget.totalBudget, releasedAmount: nextReleasedAmount, colorCode: selectedBudgetReleaseBudget.colorCode }) }) ); setBudgetReleaseForm((current) => ({ ...current, releasedAmount: nextReleasedAmount.toFixed(2) })); }, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`); } 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 handleSavePeriod(event: FormEvent) { event.preventDefault(); if (!selectedPeriodForManagement) { setMessage({ type: "error", text: "Bitte zuerst einen Zeitraum auswählen." }); return; } await runAction(async () => { await parseResponse( await fetch(`/api/periods/${selectedPeriodForManagement.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(periodEditForm) }) ); }, `Zeitraum ${periodEditForm.name.trim() || selectedPeriodForManagement.name} wurde aktualisiert.`); } 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(); const result = (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 }) }) )) as { user?: DashboardManagedUser }; if (result.user) { setManagedUsersState((current) => sortManagedUsersList([...current.filter((user) => user.id !== result.user!.id), result.user!]) ); } setUserForm({ username: "", password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "" }); 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 () => { const result = (await parseResponse( await fetch(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: draft.role, workingGroupId: draft.workingGroupId }) }) )) as { user?: DashboardManagedUser }; if (result.user) { setManagedUsersState((current) => sortManagedUsersList([...current.filter((entry) => entry.id !== result.user!.id), result.user!]) ); } 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 handleSaveOrgaSettings() { if (orgaSettingsDraft.requiredApprovalTypes.length === 0) { setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle ausw\u00e4hlen." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(orgaSettingsDraft) }) ); setIsOrgaSettingsOpen(false); }, "Zust\u00e4ndigkeiten und Benachrichtigungen wurden gespeichert."); } async function handleEnablePushNotifications() { if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { setPushStatus("unsupported"); setMessage({ type: "error", text: "Dieser Browser unterstuetzt Web Push nicht." }); return; } const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; if (!publicKey) { setMessage({ type: "error", text: "VAPID Public Key ist nicht konfiguriert." }); return; } const permission = await Notification.requestPermission(); if (permission !== "granted") { setPushStatus("blocked"); setMessage({ type: "error", text: "Benachrichtigungen wurden nicht erlaubt." }); return; } await runAction(async () => { const registration = await navigator.serviceWorker.ready; const existingSubscription = await registration.pushManager.getSubscription(); const subscription = existingSubscription ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) })); await parseResponse( await fetch("/api/push-subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription.toJSON()) }) ); setPushStatus("enabled"); }, "Web Push ist für dieses Gerät aktiviert."); } async function handleDeleteUser(userId: string) { await runAction(async () => { await parseResponse( await fetch(`/api/users/${userId}`, { method: "DELETE" }) ); setManagedUsersState((current) => current.filter((user) => user.id !== userId)); setUserDrafts((current) => { const next = { ...current }; delete next[userId]; return next; }); setPasswordDrafts((current) => { const next = { ...current }; delete next[userId]; return next; }); }, "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, availableApprovalTypes: ApprovalPermissionValue[] ) { return ( Freigaberollen {availableApprovalTypes.length > 0 ? ( availableApprovalTypes.map((approvalType) => { const selected = value.includes(approvalType); return ( ); }) ) : ( Keine Freigaberollen verfuegbar )} {helperText} ); } const islandCardSx = { borderRadius: { xs: "24px", md: "30px" }, border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98), backgroundImage: "none", 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)}`, backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.9 : 0.96), backgroundImage: "none", overflow: "hidden" }; const periodManagementPanel = canManagePeriods ? ( Zeitraum wechseln {"Nur Vorstand allgemein, AG Orga und AG Finanzen 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."} Ausgewählten Zeitraum bearbeiten setPeriodEditForm((current) => ({ ...current, name: event.target.value }))} required fullWidth disabled={!selectedPeriodForManagement} /> setPeriodEditForm((current) => ({ ...current, startsAt: event.target.value }))} InputLabelProps={{ shrink: true }} required fullWidth disabled={!selectedPeriodForManagement} /> setPeriodEditForm((current) => ({ ...current, endsAt: event.target.value }))} InputLabelProps={{ shrink: true }} required fullWidth disabled={!selectedPeriodForManagement} /> {"Abo-Berechnungen nutzen danach direkt den neuen Zeitraum."} 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 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 werden im Zeitraum automatisch Monat für Monat fortgeschrieben."} > Einmalig Monatliches Abo {expenseForm.recurrence === "MONTHLY" ? ( setExpenseForm((current) => ({ ...current, recurrenceStartAt: event.target.value })) } InputLabelProps={{ shrink: true }} fullWidth helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."} /> ) : null} 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} ))} ) : null} {canManagePeriods && (isCompactLayout || desktopSection === "overview") ? ( {"Bereits an AG übergeben"} {"Zusatzbetrag ohne Einzelposten. Bezahlt z\u00e4hlt mit."} setBudgetReleaseForm((current) => ({ ...current, workingGroupId: event.target.value, budgetId: "" })) } required fullWidth disabled={visibleGroups.length === 0} helperText={visibleGroups.length === 0 ? "Lege zuerst eine AG an." : "Wähle die AG mit dem betroffenen Budget."} > {visibleGroups.map((group) => ( {group.name} ))} setBudgetReleaseForm((current) => ({ ...current, budgetId: event.target.value })) } required fullWidth disabled={selectedBudgetReleaseOptions.length === 0} helperText={ selectedBudgetReleaseOptions.length === 0 ? "In dieser AG gibt es noch kein Budget." : "Die gestrichelte Linie im Budget zeigt die gesamte Mittelübergabe inklusive bezahlter Posten." } > {selectedBudgetReleaseOptions.map((budget) => ( {budget.name} ))} setBudgetReleaseForm((current) => ({ ...current, releasedAmount: event.target.value })) } required fullWidth disabled={!selectedBudgetReleaseBudget} helperText={ selectedBudgetReleaseBudget ? `Automatisch \u00fcber Bezahlt: ${currencyFormatter.format(selectedBudgetReleasePaidAmount)} | Zus\u00e4tzlich erfasst: ${currencyFormatter.format(selectedBudgetReleaseBudget.releasedAmount)}` : "Wähle zuerst ein Budget aus." } /> ) : 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 })); }} required > Vorstand allgemein AG Orga AG Finanzen 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: Verwaltungsrollen können einer AG zugeordnet werden." } > {userForm.role !== "MEMBER" ? Ohne AG : null} {visibleGroups.map((group) => ( {group.name} ))} {getAvailableApprovalRoles(userForm.role).length > 0 ? `Freigabe automatisch: ${getAvailableApprovalRoles(userForm.role).map(approvalLabel).join(", ")}` : "Diese Rolle kann keine Ausgaben freigeben."} 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 und Passwörter pflegen."} {managedUsersState.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 }); }} fullWidth > Vorstand allgemein AG Orga AG Finanzen 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: Verwaltungsrollen können einer AG zugeordnet werden." } > {draft.role !== "MEMBER" ? Ohne AG : null} {visibleGroups.map((group) => ( {group.name} ))} {getAvailableApprovalRoles(draft.role).length > 0 ? `Freigabe automatisch: ${getAvailableApprovalRoles(draft.role).map(approvalLabel).join(", ")}` : "Diese Rolle kann keine Ausgaben freigeben."} ) : 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 = ( {isCompactLayout ? ( {visibleGroups.map((group) => ( ))} ) : ( {visibleGroups.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"}.`} {viewer.approvalPermissions.length > 0 ? ( ) : null} {viewer.role === "ORGA" ? ( setIsOrgaSettingsOpen(true)} > ) : null} } 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} setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm"> Zuständigkeiten & Benachrichtigungen Freigaberollen Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen. {APPROVAL_FLOW.map((approvalType) => ( setOrgaSettingsDraft((current) => ({ ...current, requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType) })) } /> } label={approvalLabel(approvalType)} /> ))} setOrgaSettingsDraft((current) => ({ ...current, budgetReleaseNotifyTarget: event.target.value as OrgaSettingsDraft["budgetReleaseNotifyTarget"] })) } fullWidth helperText="Wer informiert wird, wenn ein Budget an eine AG übergeben wird." > Alle Nutzer der AG Nur AG-Mitglieder {message ? {message.text} : null} {periodOverviewCard} {isCompactLayout ? ( setMobileSection(nextValue)} variant="fullWidth" > {mobileSection === "overview" ? overviewContent : actionCards} ) : ( {desktopSectionContent} )} ); }