"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, Chip, Container, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, MenuItem, Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import { signOut } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import type { FormEvent } from "react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { BudgetColumn } from "@/components/dashboard/budget-column"; import { ColorPickerField } from "@/components/dashboard/color-picker-field"; import type { DashboardAccountingPeriod, DashboardAuditLog, DashboardDonation, DashboardManagedUser, DashboardPeriodCutoff, 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; donations: DashboardDonation[]; }; type ExpenseFormState = { title: string; description: string; amount: string; agId: string; budgetId: string; recurrence: "NONE" | "MONTHLY"; recurrenceStartAt: string; cutoffId: string; cutoffPhase: "PRE" | "POST"; }; type BudgetFormState = { workingGroupId: string; name: string; totalBudget: string; colorCode: string; }; type BudgetReleaseFormState = { workingGroupId: string; budgetId: string; releasedAmount: string; }; type DonationFormState = { title: string; description: string; amount: string; donatedAt: string; target: "GENERAL" | "EXPENSE"; workingGroupId: string; expenseId: string; }; type DonationDraft = DonationFormState; type ExpenseEditDraft = { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffId: string; cutoffPhase: "PRE" | "POST"; }; type CutoffFormState = { name: string; date: string; }; type CutoffDraft = CutoffFormState; 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; cutoffName: string; cutoffDate: string; }; type OrgaSettingsDraft = { requiredApprovalTypes: ApprovalPermissionValue[]; budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY"; }; type DashboardMessage = { type: "success" | "error"; text: string; }; type DriveDiagnosticResult = { ok?: boolean; error?: string; code?: string; details?: string[]; folderId?: string; serviceAccountEmail?: 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" | "finance" | "actions"; type MobileAction = | "expense" | "donation" | "budgetRelease" | "workingGroup" | "budget" | "periods" | "backup" | "userCreate" | "approvalThreshold" | "users" | "logs"; type FinanceViewMode = "monthly" | "yearly" | "cutoff"; type FinancePresentation = "charts" | "table"; type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs"; const GENERAL_DONATIONS_MOBILE_ID = "__general_donations__"; type CutoffSelectionOption = { value: string; cutoffId: string; cutoffPhase: "PRE" | "POST"; label: string; }; const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }); const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); const dateFormatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); function toDateInputValue(value: string) { return value.slice(0, 10); } function createCutoffSelectionValue(cutoffId: string, cutoffPhase: "PRE" | "POST") { return `${cutoffId}:${cutoffPhase}`; } function parseCutoffSelectionValue(value: string) { const [cutoffId, cutoffPhase] = value.split(":"); return { cutoffId: cutoffId ?? "", cutoffPhase: cutoffPhase === "POST" ? "POST" : "PRE" } as const; } function getCutoffSelectionOptions(cutoffs: DashboardPeriodCutoff[]): CutoffSelectionOption[] { if (cutoffs.length === 0) { return []; } const options: CutoffSelectionOption[] = cutoffs.map((cutoff) => ({ value: createCutoffSelectionValue(cutoff.id, "PRE"), cutoffId: cutoff.id, cutoffPhase: "PRE" as const, label: `Pre ${cutoff.name}` })); const lastCutoff = cutoffs[cutoffs.length - 1]; options.push({ value: createCutoffSelectionValue(lastCutoff.id, "POST"), cutoffId: lastCutoff.id, cutoffPhase: "POST", label: `Post ${lastCutoff.name}` }); return options; } function getGeneralDonationCutoffSelectionValue(cutoffs: DashboardPeriodCutoff[], donatedAt: string) { const options = getCutoffSelectionOptions(cutoffs); if (options.length === 0) { return ""; } const donationDate = new Date(donatedAt); donationDate.setHours(0, 0, 0, 0); for (const cutoff of cutoffs) { if (!cutoff.date) { continue; } const cutoffDate = new Date(cutoff.date); cutoffDate.setHours(0, 0, 0, 0); if (donationDate <= cutoffDate) { return createCutoffSelectionValue(cutoff.id, "PRE"); } } return createCutoffSelectionValue(cutoffs[cutoffs.length - 1].id, "POST"); } function getExpenseFinanceDate(expense: { createdAt: string; documents: { invoiceDate: string }[] }) { const invoiceDate = expense.documents .map((document) => document.invoiceDate) .sort((left, right) => new Date(left).getTime() - new Date(right).getTime())[0]; return new Date(invoiceDate ?? expense.createdAt); } function getFinanceMonthKey(date: Date) { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; } function getFinanceMonthLabel(date: Date) { return new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date); } 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: "", cutoffName: "Open Air", cutoffDate: "" }; } return { name: period.name, startsAt: toDateInputValue(period.startsAt), endsAt: toDateInputValue(period.endsAt), cutoffName: period.cutoffName || "Open Air", cutoffDate: period.cutoffDate ? toDateInputValue(period.cutoffDate) : "" }; } 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) { const detailText = Array.isArray(payload?.details) && payload.details.length > 0 ? `\n\n${payload.details.map((detail: string) => `- ${detail}`).join("\n")}` : ""; const codeText = typeof payload?.code === "string" ? `\nCode: ${payload.code}` : ""; throw new Error(`${payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden."}${codeText}${detailText}`); } 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)); } function hasFocusedEditableElement() { const activeElement = document.activeElement; if (!activeElement || activeElement === document.body) { return false; } return Boolean(activeElement.closest('input, textarea, select, [contenteditable="true"]')); } export function DashboardShell({ viewer, workingGroups, managedUsers, auditLogs, accountingPeriods, currentPeriodId, settings, donations }: DashboardShellProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg")); const router = useRouter(); const searchParams = useSearchParams(); 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: "AG-\u00dcbersicht" }, { value: "finance" as const, label: "Finanz\u00fcbersicht" }, ...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "AGs & Budgets" }] : []), ...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []), ...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []), ...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : []) ]; const mobileActions = [ { value: "expense" as const, label: "Neue Ausgabe" }, ...(canManagePeriods ? [{ value: "donation" as const, label: "Spende erfassen" }] : []), ...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []), ...(canManageBudgets(viewer.role) ? [ { value: "workingGroup" as const, label: "AG anlegen" }, { value: "budget" as const, label: "Budget anlegen" } ] : []), ...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []), ...(canManageAccounts ? [ { value: "backup" as const, label: "CSV-Backup" }, { value: "userCreate" as const, label: "Nutzer anlegen" }, { value: "approvalThreshold" as const, label: "Freigabe-Schwelle" }, { value: "users" as const, label: "Nutzer verwalten" }, { value: "logs" as const, label: "Änderungsverlauf" } ] : []) ]; 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()), cutoffId: currentPeriod?.cutoffs[0]?.id ?? "", cutoffPhase: "PRE" }); const [donationForm, setDonationForm] = useState({ title: "", description: "", amount: "", donatedAt: toDateInputValue(new Date().toISOString()), target: "GENERAL", workingGroupId: visibleGroups[0]?.id ?? "", expenseId: "" }); 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: "" }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); const [mobileSection, setMobileSection] = useState("overview"); const [selectedMobileAction, setSelectedMobileAction] = useState("expense"); const [desktopSection, setDesktopSection] = useState("overview"); const [financeViewMode, setFinanceViewMode] = useState("monthly"); const [financePresentation, setFinancePresentation] = useState("charts"); const [selectedFinanceMonth, setSelectedFinanceMonth] = useState("ALL"); const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? ""); const [focusedBudgetId, setFocusedBudgetId] = useState(null); 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 [driveDiagnosticResult, setDriveDiagnosticResult] = useState(null); const [donationDrafts, setDonationDrafts] = useState>({}); const [editingDonationId, setEditingDonationId] = useState(null); const [orgaSettingsDraft, setOrgaSettingsDraft] = useState({ requiredApprovalTypes: settings.requiredApprovalTypes, budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget }); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); const [cutoffForm, setCutoffForm] = useState({ name: "Open Air", date: "" }); const [cutoffDrafts, setCutoffDrafts] = useState>({}); const [editingCutoffId, setEditingCutoffId] = useState(null); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); const handledDeepLinkRef = useRef(null); const busyRef = useRef(busy); useEffect(() => { setSelectedCurrentPeriodId(currentPeriodId); setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); }, [currentPeriod, currentPeriodId]); useEffect(() => { busyRef.current = busy; }, [busy]); useEffect(() => { function refreshIfSafe() { if (document.visibilityState !== "visible" || busyRef.current || hasFocusedEditableElement()) { return; } router.refresh(); } function handleVisibilityChange() { if (document.visibilityState === "visible") { refreshIfSafe(); } } function handlePageShow() { refreshIfSafe(); } document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("focus", refreshIfSafe); window.addEventListener("pageshow", handlePageShow); const intervalId = window.setInterval(refreshIfSafe, 45_000); return () => { window.clearInterval(intervalId); document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", refreshIfSafe); window.removeEventListener("pageshow", handlePageShow); }; }, [router]); 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 (!mobileActions.some((action) => action.value === selectedMobileAction)) { setSelectedMobileAction(mobileActions[0]?.value ?? "expense"); } }, [mobileActions, selectedMobileAction]); useEffect(() => { const directGroupId = searchParams.get("group"); const budgetId = searchParams.get("budget"); const expenseId = searchParams.get("expense"); const deepLinkKey = [directGroupId, budgetId, expenseId].filter(Boolean).join(":"); if (!deepLinkKey) { handledDeepLinkRef.current = null; return; } if (handledDeepLinkRef.current === deepLinkKey) { return; } const budgetGroup = visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId)); const expenseBudget = visibleGroups .flatMap((group) => group.budgets) .find((budget) => expenseId && budget.expenses.some((expense) => expense.id === expenseId)); const targetGroupId = (directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ?? budgetGroup?.id ?? visibleGroups.find((group) => expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId)) )?.id ?? null; const targetBudgetId = budgetId ?? expenseBudget?.id ?? null; if (targetGroupId) { handledDeepLinkRef.current = deepLinkKey; setSelectedMobileGroupId(targetGroupId); setFocusedBudgetId(targetBudgetId); setMobileSection("overview"); setDesktopSection("overview"); router.replace("/", { scroll: false }); } }, [router, searchParams, visibleGroups]); useEffect(() => { if (selectedMobileGroupId === GENERAL_DONATIONS_MOBILE_ID) { return; } if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) { setSelectedMobileGroupId(visibleGroups[0]?.id ?? ""); } }, [selectedMobileGroupId, visibleGroups]); useEffect(() => { let cancelled = false; async function syncExistingPushSubscription() { if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { if (!cancelled) { setPushStatus("unsupported"); } return; } if (Notification.permission === "denied") { if (!cancelled) { setPushStatus("blocked"); } return; } if (Notification.permission !== "granted") { if (!cancelled) { setPushStatus("idle"); } return; } const registration = await navigator.serviceWorker.ready.catch(() => null); const subscription = await registration?.pushManager.getSubscription().catch(() => null); if (cancelled) { return; } if (!subscription) { setPushStatus("idle"); return; } setPushStatus("enabled"); await fetch("/api/push-subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription.toJSON()) }).catch(() => { // Der Browser hat noch eine Subscription; ein temporärer Sync-Fehler soll den UI-Status nicht zurücksetzen. }); } syncExistingPushSubscription(); return () => { cancelled = true; }; }, []); 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: "" })); } }, [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 = userForm.workingGroupId === "" || visibleGroups.some((group) => group.id === userForm.workingGroupId); if (!groupStillExists) { setUserForm((current) => ({ ...current, workingGroupId: "" })); } }, [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]); useEffect(() => { const cutoffs = currentPeriod?.cutoffs ?? []; if (cutoffs.length > 0 && !cutoffs.some((cutoff) => cutoff.id === expenseForm.cutoffId)) { setExpenseForm((current) => ({ ...current, cutoffId: cutoffs[0]?.id ?? "" })); } }, [currentPeriod, expenseForm.cutoffId]); 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 selectedDonationGroup = visibleGroups.find((group) => group.id === donationForm.workingGroupId) ?? visibleGroups[0] ?? null; const selectedDonationGroupExpenses = selectedDonationGroup?.budgets.flatMap((budget) => budget.expenses) ?? []; const selectedBudgetReleasePaidAmount = selectedBudgetReleaseBudget?.expenses.reduce( (sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 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)); const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? []; const currentCutoffs = currentPeriod?.cutoffs ?? []; const primaryCurrentCutoff = currentCutoffs[0] ?? null; const expenseCutoffOptions = useMemo(() => getCutoffSelectionOptions(currentCutoffs), [currentCutoffs]); const selectedExpenseCutoffValue = createCutoffSelectionValue(expenseForm.cutoffId, expenseForm.cutoffPhase); const selectedExpenseCutoffOption = expenseCutoffOptions.find((option) => option.value === selectedExpenseCutoffValue) ?? expenseCutoffOptions[0] ?? null; useEffect(() => { if (expenseCutoffOptions.length === 0) { return; } if (expenseCutoffOptions.some((option) => option.value === selectedExpenseCutoffValue)) { return; } const fallback = expenseCutoffOptions[0]; setExpenseForm((current) => ({ ...current, cutoffId: fallback.cutoffId, cutoffPhase: fallback.cutoffPhase })); }, [expenseCutoffOptions, selectedExpenseCutoffValue]); const allExpenses = useMemo( () => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)), [visibleGroups] ); 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 ?? "" } })); } function getCutoffDraft(cutoff: { id: string; name: string; date: string | null }): CutoffDraft { return cutoffDrafts[cutoff.id] ?? { name: cutoff.name, date: cutoff.date ? toDateInputValue(cutoff.date) : "" }; } function updateCutoffDraft(cutoff: { id: string; name: string; date: string | null }, patch: Partial) { setCutoffDrafts((current) => ({ ...current, [cutoff.id]: { ...getCutoffDraft(cutoff), ...patch } })); } function getDonationDraft(donation: DashboardDonation): DonationDraft { return donationDrafts[donation.id] ?? { title: donation.title, description: donation.description ?? "", amount: donation.amount.toFixed(2), donatedAt: toDateInputValue(donation.donatedAt), target: donation.expenseId ? "EXPENSE" : "GENERAL", workingGroupId: donation.workingGroupId ?? visibleGroups[0]?.id ?? "", expenseId: donation.expenseId ?? "" }; } function updateDonationDraft(donation: DashboardDonation, patch: Partial) { setDonationDrafts((current) => ({ ...current, [donation.id]: { ...getDonationDraft(donation), ...patch } })); } 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.netPeriodAmount : 0), 0 ), 0 ); const pending = group.budgets.reduce( (groupSum, budget) => groupSum + budget.expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 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]); const generalDonationTotal = useMemo( () => donations.reduce((sum, donation) => sum + (donation.expenseId ? 0 : donation.amount), 0), [donations] ); const assignedDonationTotal = useMemo( () => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0), [donations] ); const plannedBubble = useMemo(() => { const pendingOpen = allExpenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "PENDING" && !expense.paidAt ? expense.netPeriodAmount : 0), 0 ); const approvedOpen = allExpenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "APPROVED" && !expense.paidAt ? expense.netPeriodAmount : 0), 0 ); return { pendingOpen, approvedOpen, amount: pendingOpen + approvedOpen }; }, [allExpenses]); const plannedUntilCutoffs = useMemo( () => { const today = new Date(); today.setHours(0, 0, 0, 0); return currentCutoffs .filter((cutoff) => { if (!cutoff.date) { return false; } const cutoffDate = new Date(cutoff.date); cutoffDate.setHours(0, 0, 0, 0); return cutoffDate >= today; }) .map((cutoff) => ({ cutoff, amount: allExpenses.reduce( (sum, expense) => sum + (expense.cutoffPhase === "PRE" && !expense.paidAt && expense.cutoffId === cutoff.id ? expense.netPeriodAmount : 0), 0 ) })); }, [allExpenses, currentCutoffs] ); const paidTotal = useMemo( () => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0), [allExpenses] ); 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 : "", cutoffId: expenseForm.cutoffId, cutoffPhase: expenseForm.cutoffPhase }) }) ); 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()), cutoffId: currentPeriod?.cutoffs[0]?.id ?? "", cutoffPhase: "PRE" }); }, "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 handleCreateDonation(event: FormEvent) { event.preventDefault(); if (!currentPeriod) { setMessage({ type: "error", text: "Bitte zuerst einen aktuellen Zeitraum auswählen." }); return; } if (donationForm.target === "EXPENSE" && !donationForm.expenseId) { setMessage({ type: "error", text: "Bitte die Ausgabe auswählen, der die Spende zugeordnet werden soll." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/donations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: donationForm.title, description: donationForm.description, amount: donationForm.amount, donatedAt: donationForm.donatedAt, periodId: currentPeriod.id, expenseId: donationForm.target === "EXPENSE" ? donationForm.expenseId : "" }) }) ); setDonationForm({ title: "", description: "", amount: "", donatedAt: toDateInputValue(new Date().toISOString()), target: "GENERAL", workingGroupId: visibleGroups[0]?.id ?? "", expenseId: "" }); }, "Spende wurde erfasst."); } 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 handleUpdateExpense(expenseId: string, draft: ExpenseEditDraft) { await runAction(async () => { await parseResponse( await fetch(`/api/expenses/${expenseId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft) }) ); }, "Ausgabe wurde bearbeitet."); } async function handleUpdateDonation(donationId: string, draft: DonationDraft) { await runAction(async () => { await parseResponse( await fetch(`/api/donations/${donationId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: draft.title, description: draft.description, amount: draft.amount, donatedAt: draft.donatedAt, expenseId: draft.target === "EXPENSE" ? draft.expenseId : "" }) }) ); setEditingDonationId(null); }, "Spende wurde bearbeitet."); } async function handleDeleteDonation(donationId: string, title: string) { await runAction(async () => { await parseResponse( await fetch(`/api/donations/${donationId}`, { method: "DELETE" }) ); }, `Spende ${title} wurde gelöscht.`); } async function handleCreateCutoff(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}/cutoffs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cutoffForm) }) ); setCutoffForm({ name: "Open Air", date: "" }); }, "Stichtag wurde angelegt."); } async function handleUpdateCutoff(cutoffId: string, draft: CutoffDraft) { await runAction(async () => { await parseResponse( await fetch(`/api/period-cutoffs/${cutoffId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft) }) ); setEditingCutoffId(null); }, "Stichtag wurde bearbeitet."); } async function handleDeleteCutoff(cutoffId: string, cutoffName: string) { await runAction(async () => { await parseResponse( await fetch(`/api/period-cutoffs/${cutoffId}`, { method: "DELETE" }) ); }, `Stichtag ${cutoffName} wurde gelöscht.`); } 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({ name: periodEditForm.name, startsAt: periodEditForm.startsAt, endsAt: periodEditForm.endsAt }) }) ); }, `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: "" }); 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 gültige Freigabe-Schwelle eingeben." }); return; } if (orgaSettingsDraft.requiredApprovalTypes.length === 0) { setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle auswählen." }); return; } await runAction(async () => { await parseResponse( await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approvalThreshold: nextThreshold, requiredApprovalTypes: orgaSettingsDraft.requiredApprovalTypes }) }) ); }, `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 handleRunDriveDiagnostics() { setBusy(true); setDriveDiagnosticResult(null); setMessage(null); try { const result = (await parseResponse( await fetch("/api/settings/drive-diagnostics", { method: "POST" }) )) as DriveDiagnosticResult; setDriveDiagnosticResult(result); setMessage({ type: "success", text: "Drive-Verbindung erfolgreich getestet." }); } catch (error) { const text = error instanceof Error ? error.message : "Drive-Verbindungstest fehlgeschlagen."; setDriveDiagnosticResult({ ok: false, error: text }); setMessage({ type: "error", text }); } finally { setBusy(false); } } async function handleEnablePushNotifications() { if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { setPushStatus("unsupported"); setMessage({ type: "error", text: "Dieser Browser unterstützt 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: readonly ApprovalPermissionValue[] ) { return ( Freigaberollen {availableApprovalTypes.length > 0 ? ( availableApprovalTypes.map((approvalType) => { const selected = value.includes(approvalType); return ( ); }) ) : ( Keine Freigaberollen verfügbar )} {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 mobilePrimarySelectSx = { width: "100%", "& .MuiOutlinedInput-root": { minHeight: 64, borderRadius: "28px", backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.72 : 0.96), "& fieldset": { borderColor: alpha(theme.palette.primary.main, isDark ? 0.54 : 0.38) }, "&:hover fieldset": { borderColor: alpha(theme.palette.primary.main, 0.72) }, "&.Mui-focused fieldset": { borderWidth: 2, borderColor: theme.palette.primary.main } }, "& .MuiInputLabel-root": { color: theme.palette.text.secondary }, "& .MuiInputBase-input": { fontSize: "1.08rem", fontWeight: 600 } } as const; 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 cutoffManagementPanel = canManagePeriods ? ( Stichtage Stichtage für Pre/Post-Auswertungen anlegen, bearbeiten und löschen. {managementCutoffs.length > 0 ? ( managementCutoffs.map((cutoff) => { const draft = getCutoffDraft(cutoff); const isEditing = editingCutoffId === cutoff.id; return ( {isEditing ? ( updateCutoffDraft(cutoff, { name: event.target.value })} required fullWidth /> updateCutoffDraft(cutoff, { date: event.target.value })} InputLabelProps={{ shrink: true }} fullWidth /> ) : ( {cutoff.name} {cutoff.date ? dateFormatter.format(new Date(cutoff.date)) : "Kein Datum gesetzt"} )} ); }) ) : ( Für diesen Zeitraum gibt es noch keine Stichtage. )} Neuen Stichtag anlegen setCutoffForm((current) => ({ ...current, name: event.target.value }))} required fullWidth disabled={!selectedPeriodForManagement} /> setCutoffForm((current) => ({ ...current, date: event.target.value }))} InputLabelProps={{ shrink: true }} fullWidth disabled={!selectedPeriodForManagement} /> ) : null; const actionCards = ( {isCompactLayout ? ( setSelectedMobileAction(event.target.value as MobileAction)} fullWidth sx={mobilePrimarySelectSx} > {mobileActions.map((action) => ( {action.label} ))} ) : null} {(isCompactLayout ? selectedMobileAction === "expense" : desktopSection === "overview") ? ( Neue Ausgabe {viewer.role === "MEMBER" && !viewer.workingGroupId ? ( Du bist noch keiner AG zugeordnet. Du kannst dich anmelden, aber Ausgaben erst erfassen, wenn dir eine AG zugewiesen wurde. ) : null} 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} { const next = parseCutoffSelectionValue(event.target.value); setExpenseForm((current) => ({ ...current, cutoffId: next.cutoffId, cutoffPhase: next.cutoffPhase })); }} required fullWidth disabled={expenseCutoffOptions.length === 0 || expenseForm.recurrence === "MONTHLY"} helperText={ expenseForm.recurrence === "MONTHLY" ? "Monatliche Abos werden anhand ihrer Fälligkeit automatisch den Stichtagen zugeordnet." : undefined } > {expenseCutoffOptions.map((option) => ( {option.label} ))} 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 ? selectedMobileAction === "budgetRelease" : 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} {canManagePeriods && (isCompactLayout ? selectedMobileAction === "donation" : desktopSection === "overview") ? ( Spende erfassen Allgemeine Spenden zählen global, zugeordnete Spenden entlasten direkt eine Ausgabe. setDonationForm((current) => ({ ...current, title: event.target.value }))} required fullWidth /> setDonationForm((current) => ({ ...current, description: event.target.value }))} fullWidth multiline minRows={2} /> setDonationForm((current) => ({ ...current, amount: event.target.value }))} required fullWidth /> setDonationForm((current) => ({ ...current, donatedAt: event.target.value }))} InputLabelProps={{ shrink: true }} required fullWidth /> setDonationForm((current) => ({ ...current, target: event.target.value as DonationFormState["target"], expenseId: "" })) } fullWidth > Allgemein Ausgabe zugeordnet {donationForm.target === "EXPENSE" ? ( <> setDonationForm((current) => ({ ...current, workingGroupId: event.target.value, expenseId: "" })) } required fullWidth disabled={visibleGroups.length === 0} > {visibleGroups.map((group) => ( {group.name} ))} setDonationForm((current) => ({ ...current, expenseId: event.target.value }))} required fullWidth disabled={selectedDonationGroupExpenses.length === 0} > {selectedDonationGroupExpenses.map((expense) => ( {expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)} ))} ) : null} ) : null} {canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : 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 ? selectedMobileAction === "budget" : 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 && selectedMobileAction === "periods" ? ( {periodManagementPanel} {cutoffManagementPanel} ) : null} {canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : 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 ? selectedMobileAction === "userCreate" || selectedMobileAction === "approvalThreshold" : desktopSection === "users") ? ( {!isCompactLayout || selectedMobileAction === "userCreate" ? ( 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 Mitglied setUserForm((current) => ({ ...current, workingGroupId: event.target.value })) } fullWidth disabled={visibleGroups.length === 0} helperText={ visibleGroups.length === 0 ? "Lege zuerst eine AG an." : userForm.role === "MEMBER" ? "Optional: Mitglieder ohne AG können sich einloggen, aber noch keine Ausgaben erfassen." : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." } > Ohne AG {visibleGroups.map((group) => ( {group.name} ))} {getAvailableApprovalRoles(userForm.role).length > 0 ? `Freigabe automatisch: ${getAvailableApprovalRoles(userForm.role).map(approvalLabel).join(", ")}` : "Diese Rolle kann keine Ausgaben freigeben."} ) : null} {!isCompactLayout || selectedMobileAction === "approvalThreshold" ? ( Freigabe-Schwelle {"Ausgaben unter diesem Betrag werden automatisch freigegeben."} setApprovalThresholdDraft(event.target.value)} helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`} fullWidth /> {renderApprovalPermissionSelector( orgaSettingsDraft.requiredApprovalTypes, (approvalType) => setOrgaSettingsDraft((current) => ({ ...current, requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType) })), "Diese Rollen müssen schwellenpflichtige Ausgaben bestätigen. Mitglieder können nicht freigeben.", APPROVAL_FLOW )} ) : null} ) : null} {canManageAccounts && (isCompactLayout ? selectedMobileAction === "users" : 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 Mitglied updateManagedUserDraft(user, { workingGroupId: event.target.value })} fullWidth disabled={visibleGroups.length === 0} helperText={ visibleGroups.length === 0 ? "Lege zuerst eine AG an." : draft.role === "MEMBER" ? "Optional: Mitglieder ohne AG können sich einloggen, aber noch keine Ausgaben erfassen." : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." } > Ohne AG {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 ? selectedMobileAction === "logs" : 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 isMobileGeneralDonationsSelected = isCompactLayout && selectedMobileGroupId === GENERAL_DONATIONS_MOBILE_ID; const selectedMobileGroup = isMobileGeneralDonationsSelected ? null : visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null; const overviewGroups = isCompactLayout ? (selectedMobileGroup ? [selectedMobileGroup] : []) : visibleGroups; const generalDonations = donations.filter((donation) => !donation.expenseId); function renderDonationEditor(donation: DashboardDonation) { const draft = getDonationDraft(donation); const draftGroup = visibleGroups.find((group) => group.id === draft.workingGroupId) ?? visibleGroups[0] ?? null; const draftExpenses = draftGroup?.budgets.flatMap((budget) => budget.expenses) ?? []; if (editingDonationId !== donation.id) { return ( {donation.title} {currencyFormatter.format(donation.amount)} {donation.expenseTitle ? ` · ${donation.expenseTitle}` : ""} setEditingDonationId(donation.id)}> { if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) { return; } await handleDeleteDonation(donation.id, donation.title); }} > ); } return ( updateDonationDraft(donation, { title: event.target.value })} fullWidth /> updateDonationDraft(donation, { amount: event.target.value })} fullWidth /> updateDonationDraft(donation, { donatedAt: event.target.value })} InputLabelProps={{ shrink: true }} fullWidth /> updateDonationDraft(donation, { target: event.target.value as DonationDraft["target"], expenseId: "" }) } fullWidth > Allgemein Ausgabe zugeordnet {draft.target === "EXPENSE" ? ( <> updateDonationDraft(donation, { workingGroupId: event.target.value, expenseId: "" }) } fullWidth > {visibleGroups.map((group) => ( {group.name} ))} updateDonationDraft(donation, { expenseId: event.target.value })} fullWidth disabled={draftExpenses.length === 0} > {draftExpenses.map((expense) => ( {expense.title} ))} ) : null} ); } const generalDonationsColumn = ( Spenden Allgemein: {currencyFormatter.format(generalDonationTotal)} {generalDonations.length === 0 ? ( Noch keine allgemeinen Spenden. ) : ( generalDonations.map((donation) => ( {renderDonationEditor(donation)} )) )} ); const overviewContent = ( {isCompactLayout ? ( { setSelectedMobileGroupId(event.target.value); setFocusedBudgetId(null); }} fullWidth sx={mobilePrimarySelectSx} > {visibleGroups.map((group) => ( {group.name} ))} Spenden ) : null} {isCompactLayout ? ( {overviewGroups.map((group) => ( ))} {isMobileGeneralDonationsSelected ? generalDonationsColumn : null} ) : ( {overviewGroups.map((group) => ( ))} {generalDonationsColumn} )} ); const financeRows = (() => { if (financeViewMode === "monthly") { const rows = new Map(); for (const expense of allExpenses) { const date = getExpenseFinanceDate(expense); const key = getFinanceMonthKey(date); const row = rows.get(key) ?? { label: getFinanceMonthLabel(date), planned: 0, approved: 0, paid: 0, donations: 0 }; if (expense.approvalStatus === "PENDING") row.planned += expense.netPeriodAmount; if (expense.approvalStatus === "APPROVED") row.approved += expense.netPeriodAmount; if (expense.paidAt) row.paid += expense.netPeriodAmount; rows.set(key, row); } for (const donation of donations) { const date = new Date(donation.donatedAt); const key = getFinanceMonthKey(date); const row = rows.get(key) ?? { label: getFinanceMonthLabel(date), planned: 0, approved: 0, paid: 0, donations: 0 }; row.donations += donation.amount; rows.set(key, row); } return [...rows.entries()] .sort(([left], [right]) => left.localeCompare(right)) .filter(([key]) => selectedFinanceMonth === "ALL" || key === selectedFinanceMonth) .map(([, row]) => row); } if (financeViewMode === "cutoff") { const expenseById = new Map(allExpenses.map((expense) => [expense.id, expense])); const rows = expenseCutoffOptions.map((option) => { const expenses = allExpenses.filter( (expense) => expense.recurrence !== "MONTHLY" && expense.cutoffId === option.cutoffId && expense.cutoffPhase === option.cutoffPhase ); const monthlyOccurrences = allExpenses.flatMap((expense) => expense.recurrence === "MONTHLY" ? expense.occurrences .filter((occurrence) => getGeneralDonationCutoffSelectionValue(currentCutoffs, occurrence.dueAt) === option.value) .map((occurrence) => ({ amount: occurrence.amount, approvalStatus: expense.approvalStatus, paidAt: expense.paidAt })) : [] ); const donationsForSection = donations.filter((donation) => { if (donation.expenseId) { const expense = expenseById.get(donation.expenseId); if (expense?.recurrence === "MONTHLY") { return getGeneralDonationCutoffSelectionValue(currentCutoffs, donation.donatedAt) === option.value; } return ( expense?.cutoffId === option.cutoffId && expense.cutoffPhase === option.cutoffPhase ); } return getGeneralDonationCutoffSelectionValue(currentCutoffs, donation.donatedAt) === option.value; }); return { label: option.label, planned: expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0 ) + monthlyOccurrences.reduce( (sum, occurrence) => sum + (occurrence.approvalStatus === "PENDING" ? occurrence.amount : 0), 0 ), approved: expenses.reduce( (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0 ) + monthlyOccurrences.reduce( (sum, occurrence) => sum + (occurrence.approvalStatus === "APPROVED" ? occurrence.amount : 0), 0 ), paid: expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0) + monthlyOccurrences.reduce((sum, occurrence) => sum + (occurrence.paidAt ? occurrence.amount : 0), 0), donations: donationsForSection.reduce((sum, donation) => sum + donation.amount, 0) }; }); return rows.length > 0 ? rows : []; } return [ { label: currentPeriod?.name ?? "Jahresübersicht", planned: totals.pending, approved: totals.approved, paid: paidTotal, donations: generalDonationTotal + assignedDonationTotal } ]; })(); const financeMonthOptions = (() => { const rows = new Map(); for (const expense of allExpenses) { const date = getExpenseFinanceDate(expense); const key = getFinanceMonthKey(date); rows.set(key, getFinanceMonthLabel(date)); } for (const donation of donations) { const date = new Date(donation.donatedAt); const key = getFinanceMonthKey(date); rows.set(key, getFinanceMonthLabel(date)); } return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)); })(); const financeOverviewContent = ( setFinanceViewMode(event.target.value as FinanceViewMode)} fullWidth > Monatsübersichten Jahresübersicht Jahresübersicht Pre/Post setFinancePresentation(event.target.value as FinancePresentation)} fullWidth > Grafisch Tabellarisch {financeViewMode === "monthly" ? ( setSelectedFinanceMonth(event.target.value)} fullWidth > Alle Monate {financeMonthOptions.map(([monthKey, monthLabel]) => ( {monthLabel} ))} ) : null} {financeRows.map((row) => { const maxValue = Math.max(row.planned, row.approved, row.paid, row.donations, 1); return ( {row.label} {(["planned", "approved", "paid", "donations"] as const).map((key) => { const label = key === "planned" ? "Geplant" : key === "approved" ? "Freigegeben" : key === "paid" ? "Bezahlt" : "Spenden"; const value = row[key]; return ( {label} {currencyFormatter.format(value)} {financePresentation === "charts" ? ( ) : null} ); })} ); })} ); const desktopSectionContent = desktopSection === "overview" ? ( {actionCards} {overviewContent} ) : desktopSection === "finance" ? ( financeOverviewContent ) : desktopSection === "periods" ? ( {canManagePeriods ? ( {periodManagementPanel} ) : null} {canManagePeriods ? ( {cutoffManagementPanel} ) : null} ) : ( {actionCards} ); return ( {viewer.role === "ORGA" ? ( setIsOrgaSettingsOpen(true)} > ) : null} Rave for Peace {"RFP Finanz\u00fcbersicht"} {`Aktuelle \u00dcbersicht: ${currentPeriod?.name ?? "Zeitraum fehlt"}.`} {viewer.approvalPermissions.length > 0 ? ( ) : null} } label={`Freigegeben: ${currencyFormatter.format(totals.approved)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> Berechnung Geplant {`Geplant offen: ${currencyFormatter.format(plannedBubble.pendingOpen)}`} {`Freigegeben offen: ${currencyFormatter.format(plannedBubble.approvedOpen)}`} {`Ergebnis: ${currencyFormatter.format(plannedBubble.amount)}`} } > } label={`Geplant: ${currencyFormatter.format(plannedBubble.amount)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> } label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> {plannedUntilCutoffs.map(({ cutoff, amount }) => ( ))} {currentPeriod ? ( ) : null} {currentPeriod ? ( ) : null} setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm"> Zuständigkeiten & Benachrichtigungen 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 Mitglieder Google Drive Prüft Service Account, Zielordner und Upload-Rechte mit einer temporären Testdatei. {driveDiagnosticResult ? ( {driveDiagnosticResult.ok ? [ "Drive-Test erfolgreich.", driveDiagnosticResult.serviceAccountEmail ? `Service Account: ${driveDiagnosticResult.serviceAccountEmail}` : null, driveDiagnosticResult.folderId ? `Zielordner: ${driveDiagnosticResult.folderId}` : null, ...(driveDiagnosticResult.details ?? []) ].filter(Boolean).join("\n") : driveDiagnosticResult.error ?? "Drive-Verbindungstest fehlgeschlagen."} ) : null} {message ? {message.text} : null} {periodOverviewCard} {isCompactLayout ? ( setMobileSection(nextValue)} variant="fullWidth" > {mobileSection === "overview" ? overviewContent : mobileSection === "finance" ? financeOverviewContent : actionCards} ) : ( {desktopSectionContent} )} ); }