Files
RFP_Finanzen/src/components/dashboard/dashboard-shell.tsx
T
Jan 8d3524622c
CI / Build and Deploy (push) Successful in 2m36s
Dashboardbreite und mobile Spendenauswahl verbessern
2026-05-28 21:21:00 +02:00

4202 lines
153 KiB
TypeScript

"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<DashboardManagedUser["role"], number> = {
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<ExpenseFormState>({
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<DonationFormState>({
title: "",
description: "",
amount: "",
donatedAt: toDateInputValue(new Date().toISOString()),
target: "GENERAL",
workingGroupId: visibleGroups[0]?.id ?? "",
expenseId: ""
});
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
workingGroupId: visibleGroups[0]?.id ?? "",
name: "Hauptbudget",
totalBudget: "1200",
colorCode: "#FFB94A"
});
const [budgetReleaseForm, setBudgetReleaseForm] = useState<BudgetReleaseFormState>({
workingGroupId: visibleGroups[0]?.id ?? "",
budgetId: visibleGroups[0]?.budgets[0]?.id ?? "",
releasedAmount: (visibleGroups[0]?.budgets[0]?.releasedAmount ?? 0).toFixed(2)
});
const [workingGroupForm, setWorkingGroupForm] = useState<WorkingGroupFormState>({
name: ""
});
const [userForm, setUserForm] = useState<UserFormState>({
username: "",
password: "",
role: "MEMBER",
workingGroupId: ""
});
const [message, setMessage] = useState<DashboardMessage | null>(null);
const [busy, setBusy] = useState(false);
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
const [selectedMobileAction, setSelectedMobileAction] = useState<MobileAction>("expense");
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [financeViewMode, setFinanceViewMode] = useState<FinanceViewMode>("monthly");
const [financePresentation, setFinancePresentation] = useState<FinancePresentation>("charts");
const [selectedFinanceMonth, setSelectedFinanceMonth] = useState("ALL");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
const [backupFile, setBackupFile] = useState<File | null>(null);
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers));
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
const [donationDrafts, setDonationDrafts] = useState<Record<string, DonationDraft>>({});
const [editingDonationId, setEditingDonationId] = useState<string | null>(null);
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
requiredApprovalTypes: settings.requiredApprovalTypes,
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
});
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [cutoffForm, setCutoffForm] = useState<CutoffFormState>({
name: "Open Air",
date: ""
});
const [cutoffDrafts, setCutoffDrafts] = useState<Record<string, CutoffDraft>>({});
const [editingCutoffId, setEditingCutoffId] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
const handledDeepLinkRef = useRef<string | null>(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<ManagedUserDraft>) {
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<CutoffDraft>) {
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<DonationDraft>) {
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<T>(
task: () => Promise<T>,
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<Stack spacing={1}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Freigaberollen
</Typography>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{availableApprovalTypes.length > 0 ? (
availableApprovalTypes.map((approvalType) => {
const selected = value.includes(approvalType);
return (
<Button
key={approvalType}
type="button"
size="small"
variant={selected ? "contained" : "outlined"}
onClick={() => onToggle(approvalType)}
>
{approvalLabel(approvalType)}
</Button>
);
})
) : (
<Typography variant="body2" color="text.secondary">
Keine Freigaberollen verfügbar
</Typography>
)}
</Stack>
<Typography variant="body2" color="text.secondary">
{helperText}
</Typography>
</Stack>
);
}
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 ? (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Zeitraum wechseln
</Typography>
<Typography variant="body2" color="text.secondary">
{"Nur Vorstand allgemein, AG Orga und AG Finanzen können die aktuelle Übersicht global umstellen."}
</Typography>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(0, 1.45fr) minmax(168px, 0.85fr) minmax(220px, 0.95fr)"
},
gap: 1.2,
alignItems: "stretch"
}}
>
<TextField
select
label={"Aktuelle Übersicht"}
value={selectedCurrentPeriodId}
onChange={(event) => setSelectedCurrentPeriodId(event.target.value)}
fullWidth
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 0 }}
>
{accountingPeriods.map((period) => (
<MenuItem key={period.id} value={period.id}>
{period.name}
</MenuItem>
))}
</TextField>
<Button
variant="contained"
disabled={busy || selectedCurrentPeriodId === currentPeriodId}
onClick={handleSetCurrentPeriod}
sx={{ minWidth: 0, minHeight: 56, px: 2 }}
>
{"Übersicht setzen"}
</Button>
<Button
type="button"
color="error"
variant="outlined"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy || !selectedPeriodForManagement || selectedPeriodForManagement.isCurrent}
onClick={async () => {
if (!selectedPeriodForManagement) {
return;
}
if (!window.confirm(`Zeitraum "${selectedPeriodForManagement.name}" wirklich löschen?`)) {
return;
}
await handleDeletePeriod(selectedPeriodForManagement.id, selectedPeriodForManagement.name);
}}
sx={{
minWidth: 0,
minHeight: 56,
px: 2.5,
whiteSpace: "normal"
}}
>
{"Zeitraum löschen"}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
{selectedPeriodForManagement?.isCurrent
? "Der aktuell aktive Zeitraum kann nicht gelöscht werden."
: "Leere, nicht aktive Zeiträume lassen sich hier wieder entfernen."}
</Typography>
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Ausgewählten Zeitraum bearbeiten
</Typography>
<TextField
label="Zeitraum-Name"
value={periodEditForm.name}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, name: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
<TextField
label="Von"
type="date"
value={periodEditForm.startsAt}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, startsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Bis"
type="date"
value={periodEditForm.endsAt}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, endsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
{"Abo-Berechnungen nutzen danach direkt den neuen Zeitraum."}
</Typography>
<Button
type="submit"
variant="outlined"
startIcon={<EditRoundedIcon />}
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
>
Zeitraum speichern
</Button>
</Stack>
</Box>
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Neuen Zeitraum anlegen
</Typography>
<TextField
label="Zeitraum-Name"
value={periodForm.name}
onChange={(event) => setPeriodForm((current) => ({ ...current, name: event.target.value }))}
required
fullWidth
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
<TextField
label="Von"
type="date"
value={periodForm.startsAt}
onChange={(event) => setPeriodForm((current) => ({ ...current, startsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
/>
<TextField
label="Bis"
type="date"
value={periodForm.endsAt}
onChange={(event) => setPeriodForm((current) => ({ ...current, endsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
/>
</Stack>
<TextField
select
label={"Budgets übernehmen"}
value={periodForm.copyBudgetsFromPeriodId}
onChange={(event) =>
setPeriodForm((current) => ({
...current,
copyBudgetsFromPeriodId: event.target.value
}))
}
fullWidth
helperText={"Optional kopiert die vorhandenen Budgettöpfe direkt in den neuen Zeitraum."}
>
<MenuItem value="">Ohne Budgetübernahme</MenuItem>
{accountingPeriods.map((period) => (
<MenuItem key={period.id} value={period.id}>
{period.name}
</MenuItem>
))}
</TextField>
<Button type="submit" variant="outlined" disabled={busy}>
Zeitraum anlegen
</Button>
</Stack>
</Box>
</Stack>
) : null;
const cutoffManagementPanel = canManagePeriods ? (
<Stack spacing={1.6}>
<Stack spacing={0.6}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Stichtage
</Typography>
<Typography variant="body2" color="text.secondary">
Stichtage für Pre/Post-Auswertungen anlegen, bearbeiten und löschen.
</Typography>
</Stack>
<Stack spacing={1.2}>
{managementCutoffs.length > 0 ? (
managementCutoffs.map((cutoff) => {
const draft = getCutoffDraft(cutoff);
const isEditing = editingCutoffId === cutoff.id;
return (
<Box key={cutoff.id} sx={nestedPanelSx}>
{isEditing ? (
<Stack spacing={1.2}>
<TextField
label="Stichtag-Name"
value={draft.name}
onChange={(event) => updateCutoffDraft(cutoff, { name: event.target.value })}
required
fullWidth
/>
<TextField
label="Datum"
type="date"
value={draft.date}
onChange={(event) => updateCutoffDraft(cutoff, { date: event.target.value })}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
variant="contained"
size="small"
disabled={busy || draft.name.trim().length === 0}
onClick={() => handleUpdateCutoff(cutoff.id, draft)}
>
Speichern
</Button>
<Button
type="button"
variant="text"
size="small"
disabled={busy}
onClick={() => setEditingCutoffId(null)}
>
Abbrechen
</Button>
</Stack>
</Stack>
) : (
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" gap={1.2}>
<Box>
<Typography sx={{ fontWeight: 700 }}>{cutoff.name}</Typography>
<Typography variant="body2" color="text.secondary">
{cutoff.date ? dateFormatter.format(new Date(cutoff.date)) : "Kein Datum gesetzt"}
</Typography>
</Box>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
variant="outlined"
size="small"
startIcon={<EditRoundedIcon />}
disabled={busy}
onClick={() => {
setEditingCutoffId(cutoff.id);
setCutoffDrafts((current) => ({
...current,
[cutoff.id]: getCutoffDraft(cutoff)
}));
}}
>
Bearbeiten
</Button>
<Button
type="button"
variant="outlined"
color="error"
size="small"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy || managementCutoffs.length <= 1}
onClick={() => {
if (!window.confirm(`Stichtag "${cutoff.name}" wirklich löschen?`)) {
return;
}
handleDeleteCutoff(cutoff.id, cutoff.name);
}}
>
Löschen
</Button>
</Stack>
</Stack>
)}
</Box>
);
})
) : (
<Typography variant="body2" color="text.secondary">
Für diesen Zeitraum gibt es noch keine Stichtage.
</Typography>
)}
</Stack>
<Box component="form" onSubmit={handleCreateCutoff} sx={nestedPanelSx}>
<Stack spacing={1.2}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Neuen Stichtag anlegen
</Typography>
<TextField
label="Stichtag-Name"
value={cutoffForm.name}
onChange={(event) => setCutoffForm((current) => ({ ...current, name: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Datum"
type="date"
value={cutoffForm.date}
onChange={(event) => setCutoffForm((current) => ({ ...current, date: event.target.value }))}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!selectedPeriodForManagement}
/>
<Button type="submit" variant="outlined" disabled={busy || !selectedPeriodForManagement}>
Stichtag anlegen
</Button>
</Stack>
</Box>
</Stack>
) : null;
const actionCards = (
<Stack
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
sx={{
width: "100%",
...(!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups")
? {
display: "grid",
gridTemplateColumns: {
xl:
desktopSection === "users"
? "minmax(360px, 430px) minmax(0, 1fr)"
: "minmax(320px, 380px) minmax(0, 1fr)"
},
alignItems: "start",
gap: 3
}
: null)
}}
>
{isCompactLayout ? (
<TextField
select
label="Aktion auswählen"
value={selectedMobileAction}
onChange={(event) => setSelectedMobileAction(event.target.value as MobileAction)}
fullWidth
sx={mobilePrimarySelectSx}
>
{mobileActions.map((action) => (
<MenuItem key={action.value} value={action.value}>
{action.label}
</MenuItem>
))}
</TextField>
) : null}
{(isCompactLayout ? selectedMobileAction === "expense" : desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
Neue Ausgabe
</Typography>
</Box>
{viewer.role === "MEMBER" && !viewer.workingGroupId ? (
<Alert severity="info">
Du bist noch keiner AG zugeordnet. Du kannst dich anmelden, aber Ausgaben erst erfassen, wenn dir eine AG zugewiesen wurde.
</Alert>
) : null}
<Box component="form" onSubmit={handleCreateExpense}>
<Stack spacing={2}>
<TextField
label="Titel"
value={expenseForm.title}
onChange={(event) => setExpenseForm((current) => ({ ...current, title: event.target.value }))}
required
fullWidth
/>
<TextField
label="Beschreibung"
value={expenseForm.description}
onChange={(event) =>
setExpenseForm((current) => ({ ...current, description: event.target.value }))
}
fullWidth
multiline
minRows={3}
/>
<TextField
label="Betrag in EUR"
type="number"
inputProps={{ min: 0.01, step: 0.01 }}
value={expenseForm.amount}
onChange={(event) => setExpenseForm((current) => ({ ...current, amount: event.target.value }))}
required
fullWidth
/>
<TextField
select
label="Wiederholung"
value={expenseForm.recurrence}
onChange={(event) =>
setExpenseForm((current) => ({
...current,
recurrence: event.target.value as ExpenseFormState["recurrence"]
}))
}
fullWidth
helperText={"Monatliche Abos werden im Zeitraum automatisch Monat für Monat fortgeschrieben."}
>
<MenuItem value="NONE">Einmalig</MenuItem>
<MenuItem value="MONTHLY">Monatliches Abo</MenuItem>
</TextField>
{expenseForm.recurrence === "MONTHLY" ? (
<TextField
label="Abo-Startdatum"
type="date"
value={expenseForm.recurrenceStartAt}
onChange={(event) =>
setExpenseForm((current) => ({ ...current, recurrenceStartAt: event.target.value }))
}
InputLabelProps={{ shrink: true }}
fullWidth
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
/>
) : null}
<TextField
select
label="Stichtag-Zuordnung"
value={selectedExpenseCutoffOption?.value ?? ""}
onChange={(event) => {
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) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
select
label="Arbeitsgruppe"
value={expenseForm.agId}
onChange={(event) => 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) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Budget"
value={expenseForm.budgetId}
onChange={(event) => 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) => (
<MenuItem key={budget.id} value={budget.id}>
{budget.name}
</MenuItem>
))}
</TextField>
<Button
type="submit"
variant="contained"
startIcon={<AddRoundedIcon />}
disabled={busy || selectedBudgetOptions.length === 0}
>
Ausgabe speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManagePeriods && (isCompactLayout ? selectedMobileAction === "budgetRelease" : desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
{"Bereits an AG übergeben"}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: "0.9rem" }}>
{"Zusatzbetrag ohne Einzelposten. Bezahlt z\u00e4hlt mit."}
</Typography>
</Box>
<Box component="form" onSubmit={handleSaveBudgetRelease}>
<Stack spacing={2}>
<TextField
select
label="Arbeitsgruppe"
value={budgetReleaseForm.workingGroupId}
onChange={(event) =>
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) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Budget"
value={budgetReleaseForm.budgetId}
onChange={(event) =>
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) => (
<MenuItem key={budget.id} value={budget.id}>
{budget.name}
</MenuItem>
))}
</TextField>
<TextField
label="Zusätzlich bereits übergeben in EUR"
type="number"
inputProps={{ min: 0, step: 0.01, max: selectedBudgetReleaseBudget?.totalBudget ?? undefined }}
value={budgetReleaseForm.releasedAmount}
onChange={(event) =>
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."
}
/>
<Button type="submit" variant="outlined" disabled={busy || !selectedBudgetReleaseBudget}>
Betrag speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManagePeriods && (isCompactLayout ? selectedMobileAction === "donation" : desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
Spende erfassen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: "0.9rem" }}>
Allgemeine Spenden zählen global, zugeordnete Spenden entlasten direkt eine Ausgabe.
</Typography>
</Box>
<Box component="form" onSubmit={handleCreateDonation}>
<Stack spacing={2}>
<TextField
label="Titel"
value={donationForm.title}
onChange={(event) => setDonationForm((current) => ({ ...current, title: event.target.value }))}
required
fullWidth
/>
<TextField
label="Beschreibung"
value={donationForm.description}
onChange={(event) => setDonationForm((current) => ({ ...current, description: event.target.value }))}
fullWidth
multiline
minRows={2}
/>
<TextField
label="Betrag in EUR"
type="number"
inputProps={{ min: 0.01, step: 0.01 }}
value={donationForm.amount}
onChange={(event) => setDonationForm((current) => ({ ...current, amount: event.target.value }))}
required
fullWidth
/>
<TextField
label="Spendendatum"
type="date"
value={donationForm.donatedAt}
onChange={(event) => setDonationForm((current) => ({ ...current, donatedAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
/>
<TextField
select
label="Zuordnung"
value={donationForm.target}
onChange={(event) =>
setDonationForm((current) => ({
...current,
target: event.target.value as DonationFormState["target"],
expenseId: ""
}))
}
fullWidth
>
<MenuItem value="GENERAL">Allgemein</MenuItem>
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
</TextField>
{donationForm.target === "EXPENSE" ? (
<>
<TextField
select
label="AG"
value={donationForm.workingGroupId}
onChange={(event) =>
setDonationForm((current) => ({
...current,
workingGroupId: event.target.value,
expenseId: ""
}))
}
required
fullWidth
disabled={visibleGroups.length === 0}
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Ausgabe"
value={donationForm.expenseId}
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
required
fullWidth
disabled={selectedDonationGroupExpenses.length === 0}
>
{selectedDonationGroupExpenses.map((expense) => (
<MenuItem key={expense.id} value={expense.id}>
{expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)}
</MenuItem>
))}
</TextField>
</>
) : null}
<Button type="submit" variant="outlined" disabled={busy}>
Spende speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
AG anlegen
</Typography>
<Typography color="text.secondary">
{"Lege Arbeitsgruppen separat an. Bearbeiten oder löschen geht danach direkt in der Übersicht per Stift."}
</Typography>
</Box>
<Box component="form" onSubmit={handleCreateWorkingGroup}>
<Stack spacing={2}>
<TextField
label="AG-Name"
value={workingGroupForm.name}
onChange={(event) =>
setWorkingGroupForm((current) => ({ ...current, name: event.target.value }))
}
required
fullWidth
/>
<Button type="submit" variant="outlined" disabled={busy}>
AG speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "budget" : desktopSection === "budgetGroups") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
Budget anlegen
</Typography>
<Typography color="text.secondary">
{"Neue Budgett\u00f6pfe werden immer f\u00fcr den aktuell ausgew\u00e4hlten Abrechnungszeitraum angelegt."}
</Typography>
</Box>
{currentPeriod ? (
<Chip
label={`Zeitraum: ${currentPeriod.name}`}
variant="outlined"
sx={{ width: "fit-content" }}
/>
) : null}
<Box component="form" onSubmit={handleUpsertBudget}>
<Stack spacing={2}>
<TextField
select
label="Arbeitsgruppe"
value={budgetForm.workingGroupId}
onChange={(event) =>
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) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<Typography variant="body2" color="text.secondary">
{selectedBudgetWorkingGroup
? `Neue Budgettöpfe landen in ${selectedBudgetWorkingGroup.name}.`
: "Wähle zuerst eine bestehende AG aus."}
</Typography>
<TextField
label="Budget-Name"
value={budgetForm.name}
onChange={(event) => setBudgetForm((current) => ({ ...current, name: event.target.value }))}
required
/>
<TextField
label="Budgetbetrag"
type="number"
inputProps={{ min: 0, step: 0.01 }}
value={budgetForm.totalBudget}
onChange={(event) =>
setBudgetForm((current) => ({ ...current, totalBudget: event.target.value }))
}
required
/>
<ColorPickerField
label="Budgetfarbe"
value={budgetForm.colorCode}
onChange={(value) => setBudgetForm((current) => ({ ...current, colorCode: value }))}
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1} useFlexGap flexWrap="wrap">
<Button type="submit" variant="outlined" disabled={busy || visibleGroups.length === 0}>
Budget speichern
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
<Stack spacing={3}>
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
</Card>
</Stack>
) : null}
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
CSV-Backup
</Typography>
<Typography color="text.secondary">
{"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."}
</Typography>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} useFlexGap flexWrap="wrap">
<Button
component="a"
href="/api/export/csv"
variant="outlined"
startIcon={<DownloadRoundedIcon />}
>
CSV herunterladen
</Button>
<Button component="label" variant="outlined" disabled={busy}>
CSV auswählen
<input
hidden
type="file"
accept=".csv,text/csv"
onChange={(event) => setBackupFile(event.target.files?.[0] ?? null)}
/>
</Button>
<Button
type="button"
variant="contained"
disabled={busy || !backupFile}
onClick={handleImportBackup}
>
Backup einspielen
</Button>
</Stack>
<Typography variant="body2" color="text.secondary">
{backupFile
? `Ausgewählt: ${backupFile.name}`
: "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."}
</Typography>
</Stack>
</CardContent>
</Card>
) : null}
{canManageAccounts &&
(isCompactLayout
? selectedMobileAction === "userCreate" || selectedMobileAction === "approvalThreshold"
: desktopSection === "users") ? (
<Stack spacing={3}>
{!isCompactLayout || selectedMobileAction === "userCreate" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
Nutzer anlegen
</Typography>
<Typography color="text.secondary">
{"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."}
</Typography>
</Box>
<Box component="form" onSubmit={handleCreateUser}>
<Stack spacing={2}>
<TextField
label="Login-Name"
helperText={"Damit melden sich Nutzer später an und so werden sie auch angezeigt."}
value={userForm.username}
onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
required
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
<TextField
label="Startpasswort"
value={userForm.password}
onChange={(event) =>
setUserForm((current) => ({ ...current, password: event.target.value }))
}
required
fullWidth
helperText={"Dieses Passwort wird nach dem Anlegen oben als Bestätigung angezeigt."}
/>
<Button
type="button"
variant="outlined"
onClick={() =>
setUserForm((current) => ({
...current,
password: generatePassword()
}))
}
sx={{ minWidth: { sm: 148 } }}
>
Generieren
</Button>
</Stack>
<TextField
select
label="Rolle"
value={userForm.role}
onChange={(event) => {
const nextRole = event.target.value as UserFormState["role"];
setUserForm((current) => ({
...current,
role: nextRole
}));
}}
required
>
<MenuItem value="BOARD">Vorstand allgemein</MenuItem>
<MenuItem value="ORGA">AG Orga</MenuItem>
<MenuItem value="FINANCE">AG Finanzen</MenuItem>
<MenuItem value="MEMBER">Mitglied</MenuItem>
</TextField>
<TextField
select
label="AG-Zuordnung"
value={userForm.workingGroupId}
onChange={(event) =>
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."
}
>
<MenuItem value="">Ohne AG</MenuItem>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<Typography variant="body2" color="text.secondary">
{getAvailableApprovalRoles(userForm.role).length > 0
? `Freigabe automatisch: ${getAvailableApprovalRoles(userForm.role).map(approvalLabel).join(", ")}`
: "Diese Rolle kann keine Ausgaben freigeben."}
</Typography>
<Button type="submit" variant="outlined" disabled={busy}>
Nutzer speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{!isCompactLayout || selectedMobileAction === "approvalThreshold" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
Freigabe-Schwelle
</Typography>
<Typography color="text.secondary">
{"Ausgaben unter diesem Betrag werden automatisch freigegeben."}
</Typography>
</Box>
<TextField
label="Schwelle in EUR"
type="number"
inputProps={{ min: 0, step: 0.01 }}
value={approvalThresholdDraft}
onChange={(event) => 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
)}
<Button type="button" variant="outlined" disabled={busy} onClick={handleSaveApprovalThreshold}>
Schwelle und Freigaberollen speichern
</Button>
</Stack>
</CardContent>
</Card>
) : null}
</Stack>
) : null}
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "users" : desktopSection === "users") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
Nutzer verwalten
</Typography>
<Typography color="text.secondary">
{"Bestehende Passwörter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung und Passwörter pflegen."}
</Typography>
</Box>
<Stack spacing={1.4}>
{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 (
<Box key={user.id} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
gap={1.5}
alignItems={{ xs: "stretch", sm: "flex-start" }}
>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{user.username}</Typography>
<Typography color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
{roleLabel(user.role)}
</Typography>
</Box>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
size="small"
variant={isEditingUser ? "contained" : "outlined"}
startIcon={<EditRoundedIcon />}
disabled={busy}
onClick={() => {
if (isEditingUser) {
setEditingUserId(null);
return;
}
openUserEditor(user);
}}
>
Bearbeiten
</Button>
<Button
size="small"
variant={isResetOpen ? "contained" : "outlined"}
startIcon={<KeyRoundedIcon />}
disabled={busy}
onClick={() => {
if (isResetOpen) {
setEditingPasswordUserId(null);
return;
}
openPasswordReset(user.id);
}}
>
Passwort setzen
</Button>
<Button
size="small"
color="error"
variant="outlined"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy || !canDelete}
onClick={async () => {
if (!window.confirm(`Nutzer "${user.username}" wirklich löschen?`)) {
return;
}
await handleDeleteUser(user.id);
}}
>
{"Löschen"}
</Button>
</Stack>
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip label={user.workingGroupName ?? "Ohne AG"} size="small" variant="outlined" />
<Chip label={`Ausgaben: ${user.createdExpensesCount}`} size="small" variant="outlined" />
<Chip label={`Freigaben: ${user.approvalsCount}`} size="small" variant="outlined" />
{user.approvalPermissions.length > 0 ? (
user.approvalPermissions.map((approvalType) => (
<Chip key={approvalType} label={approvalLabel(approvalType)} size="small" color="primary" variant="outlined" />
))
) : (
<Chip label="Keine Freigaberolle" size="small" variant="outlined" />
)}
</Stack>
{isEditingUser ? (
<Box
sx={{
...nestedPanelSx,
p: 1.5,
borderColor: alpha(theme.palette.primary.main, 0.18),
background: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06),
backgroundImage: "none"
}}
>
<Stack spacing={1.4}>
<TextField
select
label="Rolle"
value={draft.role}
onChange={(event) => {
const nextRole = event.target.value as ManagedUserDraft["role"];
updateManagedUserDraft(user, {
role: nextRole
});
}}
fullWidth
>
<MenuItem value="BOARD">Vorstand allgemein</MenuItem>
<MenuItem value="ORGA">AG Orga</MenuItem>
<MenuItem value="FINANCE">AG Finanzen</MenuItem>
<MenuItem value="MEMBER">Mitglied</MenuItem>
</TextField>
<TextField
select
label="AG-Zuordnung"
value={draft.workingGroupId}
onChange={(event) => 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."
}
>
<MenuItem value="">Ohne AG</MenuItem>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<Typography variant="body2" color="text.secondary">
{getAvailableApprovalRoles(draft.role).length > 0
? `Freigabe automatisch: ${getAvailableApprovalRoles(draft.role).map(approvalLabel).join(", ")}`
: "Diese Rolle kann keine Ausgaben freigeben."}
</Typography>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button type="button" variant="contained" disabled={busy} onClick={() => handleUpdateUser(user)}>
Nutzer speichern
</Button>
<Button
type="button"
variant="text"
disabled={busy}
onClick={() => {
resetManagedUserDraft(user);
setEditingUserId(null);
}}
>
Abbrechen
</Button>
</Stack>
</Stack>
</Box>
) : null}
{isResetOpen ? (
<Box
sx={{
...nestedPanelSx,
p: 1.5,
borderColor: alpha(theme.palette.primary.main, 0.18),
background: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06),
backgroundImage: "none"
}}
>
<Stack spacing={1.2}>
<TextField
label="Neues Passwort"
value={passwordDrafts[user.id] ?? ""}
onChange={(event) =>
setPasswordDrafts((current) => ({
...current,
[user.id]: event.target.value
}))
}
fullWidth
helperText={
"Nur neu gesetzte Passwörter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen."
}
/>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
variant="outlined"
disabled={busy}
onClick={() =>
setPasswordDrafts((current) => ({
...current,
[user.id]: generatePassword()
}))
}
>
Generieren
</Button>
<Button
type="button"
variant="contained"
disabled={busy}
onClick={() => handleResetPassword(user.id, user.username)}
>
Passwort speichern
</Button>
<Button
type="button"
variant="text"
disabled={busy}
onClick={() => setEditingPasswordUserId(null)}
>
Abbrechen
</Button>
</Stack>
</Stack>
</Box>
) : null}
</Stack>
</Box>
);
})}
</Stack>
</Stack>
</CardContent>
</Card>
) : null}
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "logs" : desktopSection === "logs") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
{"\u00c4nderungsverlauf"}
</Typography>
<Typography color="text.secondary">
{"Zeigt die letzten \u00c4nderungen an Nutzern, Ausgaben, Budgets, AGs und Zeitr\u00e4umen."}
</Typography>
</Box>
{auditLogs.length > 0 ? (
<Stack spacing={1.2}>
{auditLogs.map((entry) => (
<Box key={entry.id} sx={nestedPanelSx}>
<Stack spacing={1}>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
gap={1}
alignItems={{ xs: "flex-start", sm: "center" }}
>
<Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{entry.summary}</Typography>
<Chip
label={dateTimeFormatter.format(new Date(entry.createdAt))}
size="small"
variant="outlined"
/>
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip label={entry.entityType} size="small" variant="outlined" />
{entry.entityLabel ? <Chip label={entry.entityLabel} size="small" variant="outlined" /> : null}
<Chip label={entry.action} size="small" variant="outlined" />
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
{entry.actor
? `${entry.actor.username} - ${roleLabel(entry.actor.role)}`
: "Systemeintrag"}
</Typography>
{entry.canRestore ? (
<Button
type="button"
variant="outlined"
size="small"
disabled={busy}
onClick={() => handleRestoreAuditLog(entry.id, entry.summary)}
sx={{ alignSelf: "flex-start" }}
>
Zustand zurücksetzen
</Button>
) : null}
</Stack>
</Box>
))}
</Stack>
) : (
<Typography color="text.secondary">{"Noch keine \u00c4nderungen protokolliert."}</Typography>
)}
</Stack>
</CardContent>
</Card>
) : null}
</Stack>
);
const periodOverviewCard = isCompactLayout ? null : showDesktopSectionTabs ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: { xs: 1.25, md: 1.5 } }}>
<Tabs
value={desktopSection}
onChange={(_, nextValue: DesktopSection) => 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) => (
<Tab key={section.value} value={section.value} label={section.label} />
))}
</Tabs>
</CardContent>
</Card>
) : 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 (
<Stack spacing={0.8}>
<Stack direction="row" justifyContent="space-between" gap={1}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, overflowWrap: "break-word" }}>
{donation.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{currencyFormatter.format(donation.amount)}
{donation.expenseTitle ? ` · ${donation.expenseTitle}` : ""}
</Typography>
</Box>
<Stack direction="row" gap={0.5}>
<IconButton size="small" disabled={busy} onClick={() => setEditingDonationId(donation.id)}>
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
return;
}
await handleDeleteDonation(donation.id, donation.title);
}}
>
<DeleteOutlineRoundedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
</Stack>
);
}
return (
<Stack spacing={1}>
<TextField
label="Titel"
size="small"
value={draft.title}
onChange={(event) => updateDonationDraft(donation, { title: event.target.value })}
fullWidth
/>
<TextField
label="Betrag in EUR"
type="number"
size="small"
inputProps={{ min: 0.01, step: 0.01 }}
value={draft.amount}
onChange={(event) => updateDonationDraft(donation, { amount: event.target.value })}
fullWidth
/>
<TextField
label="Spendendatum"
type="date"
size="small"
value={draft.donatedAt}
onChange={(event) => updateDonationDraft(donation, { donatedAt: event.target.value })}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
select
label="Zuordnung"
size="small"
value={draft.target}
onChange={(event) =>
updateDonationDraft(donation, {
target: event.target.value as DonationDraft["target"],
expenseId: ""
})
}
fullWidth
>
<MenuItem value="GENERAL">Allgemein</MenuItem>
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
</TextField>
{draft.target === "EXPENSE" ? (
<>
<TextField
select
label="AG"
size="small"
value={draft.workingGroupId}
onChange={(event) =>
updateDonationDraft(donation, {
workingGroupId: event.target.value,
expenseId: ""
})
}
fullWidth
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Ausgabe"
size="small"
value={draft.expenseId}
onChange={(event) => updateDonationDraft(donation, { expenseId: event.target.value })}
fullWidth
disabled={draftExpenses.length === 0}
>
{draftExpenses.map((expense) => (
<MenuItem key={expense.id} value={expense.id}>
{expense.title}
</MenuItem>
))}
</TextField>
</>
) : null}
<Stack direction="row" gap={1}>
<Button size="small" variant="contained" disabled={busy} onClick={() => handleUpdateDonation(donation.id, draft)}>
Speichern
</Button>
<Button size="small" variant="text" disabled={busy} onClick={() => setEditingDonationId(null)}>
Abbrechen
</Button>
</Stack>
</Stack>
);
}
const generalDonationsColumn = (
<Card sx={{ ...islandCardSx, width: { xs: "100%", lg: 286 }, flex: "0 0 auto" }}>
<CardContent sx={{ p: 2.2 }}>
<Stack spacing={1.4}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
Spenden
</Typography>
<Typography color="text.secondary">
Allgemein: {currencyFormatter.format(generalDonationTotal)}
</Typography>
</Box>
{generalDonations.length === 0 ? (
<Box sx={{ p: 1.5, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.12) }}>
<Typography variant="body2" color="text.secondary">
Noch keine allgemeinen Spenden.
</Typography>
</Box>
) : (
generalDonations.map((donation) => (
<Box key={donation.id} sx={{ p: 1.4, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.16) }}>
{renderDonationEditor(donation)}
</Box>
))
)}
</Stack>
</CardContent>
</Card>
);
const overviewContent = (
<Stack spacing={2.5}>
{isCompactLayout ? (
<TextField
select
label="AG auswählen"
value={isMobileGeneralDonationsSelected ? GENERAL_DONATIONS_MOBILE_ID : selectedMobileGroup?.id ?? ""}
onChange={(event) => {
setSelectedMobileGroupId(event.target.value);
setFocusedBudgetId(null);
}}
fullWidth
sx={mobilePrimarySelectSx}
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
<MenuItem value={GENERAL_DONATIONS_MOBILE_ID}>Spenden</MenuItem>
</TextField>
) : null}
{isCompactLayout ? (
<Box
sx={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
overflowX: "auto",
overflowY: "hidden",
pb: 2,
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain",
touchAction: "pan-x pan-y"
}}
>
<Stack direction="row" gap={2} sx={{ width: "100%", minWidth: "100%", alignItems: "stretch" }}>
{overviewGroups.map((group) => (
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
group={group}
workingGroups={visibleGroups}
cutoffs={currentCutoffs}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
onUploadProof={handleUploadProof}
onSaveWorkingGroup={handleSaveWorkingGroup}
onDeleteWorkingGroup={handleDeleteWorkingGroup}
onSaveBudget={handleSaveBudget}
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
onUpdateDonation={handleUpdateDonation}
onDeleteDonation={handleDeleteDonation}
/>
</Box>
))}
{isMobileGeneralDonationsSelected ? generalDonationsColumn : null}
</Stack>
</Box>
) : (
<Box
sx={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
overflowX: "auto",
overflowY: "hidden",
pb: 2,
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain",
touchAction: "pan-x pan-y"
}}
>
<Stack
direction="row"
gap={2}
sx={{
width: "max-content",
minWidth: "100%",
alignItems: "stretch",
pr: 0.5
}}
>
{overviewGroups.map((group) => (
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
group={group}
workingGroups={visibleGroups}
cutoffs={currentCutoffs}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
onUploadProof={handleUploadProof}
onSaveWorkingGroup={handleSaveWorkingGroup}
onDeleteWorkingGroup={handleDeleteWorkingGroup}
onSaveBudget={handleSaveBudget}
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
onUpdateDonation={handleUpdateDonation}
onDeleteDonation={handleDeleteDonation}
/>
</Box>
))}
{generalDonationsColumn}
</Stack>
</Box>
)}
</Stack>
);
const financeRows = (() => {
if (financeViewMode === "monthly") {
const rows = new Map<string, { label: string; planned: number; approved: number; paid: number; donations: number }>();
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<string, string>();
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 = (
<Stack spacing={2.5}>
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
<Stack direction={{ xs: "column", md: "row" }} gap={1.2}>
<TextField
select
label="Ansicht auswählen"
value={financeViewMode}
onChange={(event) => setFinanceViewMode(event.target.value as FinanceViewMode)}
fullWidth
>
<MenuItem value="monthly">Monatsübersichten</MenuItem>
<MenuItem value="yearly">Jahresübersicht</MenuItem>
<MenuItem value="cutoff">Jahresübersicht Pre/Post</MenuItem>
</TextField>
<TextField
select
label="Darstellung"
value={financePresentation}
onChange={(event) => setFinancePresentation(event.target.value as FinancePresentation)}
fullWidth
>
<MenuItem value="charts">Grafisch</MenuItem>
<MenuItem value="table">Tabellarisch</MenuItem>
</TextField>
{financeViewMode === "monthly" ? (
<TextField
select
label="Monat"
value={selectedFinanceMonth}
onChange={(event) => setSelectedFinanceMonth(event.target.value)}
fullWidth
>
<MenuItem value="ALL">Alle Monate</MenuItem>
{financeMonthOptions.map(([monthKey, monthLabel]) => (
<MenuItem key={monthKey} value={monthKey}>
{monthLabel}
</MenuItem>
))}
</TextField>
) : null}
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
<Chip label={`Bezahlt: ${currencyFormatter.format(paidTotal)}`} color="info" />
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
<Chip label={`Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
</Stack>
</Stack>
</CardContent>
</Card>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: financePresentation === "charts" ? "repeat(3, minmax(0, 1fr))" : "1fr" },
gap: 2
}}
>
{financeRows.map((row) => {
const maxValue = Math.max(row.planned, row.approved, row.paid, row.donations, 1);
return (
<Card key={row.label} sx={islandCardSx}>
<CardContent sx={{ p: 2.5 }}>
<Stack spacing={1.4}>
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
{row.label}
</Typography>
{(["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 (
<Box key={key}>
<Stack direction="row" justifyContent="space-between" gap={1}>
<Typography variant="body2">{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{currencyFormatter.format(value)}
</Typography>
</Stack>
{financePresentation === "charts" ? (
<Box sx={{ height: 8, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.1), overflow: "hidden" }}>
<Box
sx={{
width: `${Math.min((value / maxValue) * 100, 100)}%`,
height: "100%",
bgcolor: key === "donations" ? theme.palette.success.main : key === "paid" ? theme.palette.info.main : theme.palette.primary.main
}}
/>
</Box>
) : null}
</Box>
);
})}
</Stack>
</CardContent>
</Card>
);
})}
</Box>
</Stack>
);
const desktopSectionContent =
desktopSection === "overview" ? (
<Stack
direction={{ xs: "column", xl: "row" }}
gap={3}
alignItems="flex-start"
sx={{ width: "100%", minWidth: 0, overflowX: "hidden" }}
>
<Box sx={{ width: { xs: "100%", xl: 380 }, flexShrink: 0 }}>{actionCards}</Box>
<Box sx={{ flex: "1 1 0%", minWidth: 0, maxWidth: "100%", overflowX: "hidden" }}>{overviewContent}</Box>
</Stack>
) : desktopSection === "finance" ? (
financeOverviewContent
) : desktopSection === "periods" ? (
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
{canManagePeriods ? (
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 560 }, flexShrink: 0 }}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
) : null}
{canManagePeriods ? (
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 420 }, flexShrink: 0 }}>
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
</Card>
) : null}
</Stack>
) : (
<Box sx={{ width: "100%" }}>{actionCards}</Box>
);
return (
<Box sx={{ pb: 8 }}>
<Box sx={{ px: 2, py: { xs: 3, md: 5 } }}>
<Container maxWidth={false} sx={{ width: "100%", maxWidth: "none" }}>
<Card
sx={{
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, isDark ? 0.78 : 0.96)} 0%, ${alpha(isDark ? "#080D19" : "#17203A", 0.96)} 72%)`,
color: "white",
overflow: "hidden"
}}
>
<CardContent sx={{ p: { xs: 3, md: 4 }, position: "relative" }}>
{viewer.role === "ORGA" ? (
<IconButton
aria-label="Zuständigkeiten und Benachrichtigungen"
sx={{
position: "absolute",
top: { xs: 18, md: 24 },
right: { xs: 18, md: 24 },
width: 44,
height: 44,
border: `1px solid ${alpha("#FFFFFF", 0.28)}`,
color: "white",
bgcolor: alpha("#FFFFFF", 0.08),
"&:hover": {
bgcolor: alpha("#FFFFFF", 0.16)
}
}}
onClick={() => setIsOrgaSettingsOpen(true)}
>
<SettingsRoundedIcon />
</IconButton>
) : null}
<Stack spacing={3}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
gap={2}
>
<Box sx={{ maxWidth: 760, pr: viewer.role === "ORGA" ? { xs: 6, md: 0 } : 0 }}>
<Typography variant="overline" sx={{ color: alpha("#FFFFFF", 0.72), letterSpacing: "0.18em" }}>
Rave for Peace
</Typography>
<Typography variant="h1" sx={{ color: "inherit", mb: 1.25 }}>
{"RFP Finanz\u00fcbersicht"}
</Typography>
<Typography
variant="body1"
sx={{ color: alpha("#FFFFFF", 0.82), maxWidth: 760, fontSize: { xs: "1rem", md: "1.08rem" } }}
>
{`Aktuelle \u00dcbersicht: ${currentPeriod?.name ?? "Zeitraum fehlt"}.`}
</Typography>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
{viewer.approvalPermissions.length > 0 ? (
<Button
type="button"
size="small"
variant={pushStatus === "enabled" ? "contained" : "outlined"}
startIcon={<NotificationsActiveRoundedIcon />}
disabled={busy || pushStatus === "unsupported"}
sx={{ borderColor: alpha("#FFFFFF", 0.28), color: "white" }}
onClick={handleEnablePushNotifications}
>
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
</Button>
) : null}
<Chip
label={`${viewer.username} - ${roleLabel(viewer.role)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}
/>
<Button
variant="outlined"
startIcon={<LogoutRoundedIcon />}
sx={{ borderColor: alpha("#FFFFFF", 0.28), color: "white" }}
onClick={() => signOut({ callbackUrl: "/login" })}
>
Abmelden
</Button>
</Stack>
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip
icon={<VerifiedRoundedIcon />}
label={`Freigegeben: ${currencyFormatter.format(totals.approved)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
<Tooltip
arrow
title={
<Stack spacing={0.4}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
Berechnung Geplant
</Typography>
<Typography variant="caption">
{`Geplant offen: ${currencyFormatter.format(plannedBubble.pendingOpen)}`}
</Typography>
<Typography variant="caption">
{`Freigegeben offen: ${currencyFormatter.format(plannedBubble.approvedOpen)}`}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{`Ergebnis: ${currencyFormatter.format(plannedBubble.amount)}`}
</Typography>
</Stack>
}
>
<Chip
icon={<WalletRoundedIcon />}
label={`Geplant: ${currencyFormatter.format(plannedBubble.amount)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
</Tooltip>
<Chip
icon={<SavingsRoundedIcon />}
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
{plannedUntilCutoffs.map(({ cutoff, amount }) => (
<Chip
key={cutoff.id}
label={`Geplant bis ${cutoff.name}: ${currencyFormatter.format(amount)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
))}
<Chip
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
{currentPeriod ? (
<Chip
label={currentPeriod.name}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
) : null}
{currentPeriod ? (
<Chip
label={formatPeriodRange(currentPeriod.startsAt, currentPeriod.endsAt)}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
) : null}
</Stack>
</Stack>
</CardContent>
</Card>
</Container>
</Box>
<Dialog open={isOrgaSettingsOpen} onClose={() => setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Zuständigkeiten & Benachrichtigungen</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ pt: 1 }}>
<TextField
select
label="Budgetfreigabe-Push"
value={orgaSettingsDraft.budgetReleaseNotifyTarget}
onChange={(event) =>
setOrgaSettingsDraft((current) => ({
...current,
budgetReleaseNotifyTarget: event.target.value as OrgaSettingsDraft["budgetReleaseNotifyTarget"]
}))
}
fullWidth
helperText="Wer informiert wird, wenn ein Budget an eine AG übergeben wird."
>
<MenuItem value="ALL_GROUP_USERS">Alle Nutzer der AG</MenuItem>
<MenuItem value="GROUP_MEMBERS_ONLY">Nur Mitglieder</MenuItem>
</TextField>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Google Drive
</Typography>
<Typography variant="body2" color="text.secondary">
Prüft Service Account, Zielordner und Upload-Rechte mit einer temporären Testdatei.
</Typography>
<Button
type="button"
variant="outlined"
disabled={busy}
onClick={handleRunDriveDiagnostics}
sx={{ mt: 1.2 }}
>
Drive-Verbindung testen
</Button>
{driveDiagnosticResult ? (
<Alert
severity={driveDiagnosticResult.ok ? "success" : "error"}
sx={{ mt: 1.2, whiteSpace: "pre-line" }}
>
{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."}
</Alert>
) : null}
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsOrgaSettingsOpen(false)}>Abbrechen</Button>
<Button variant="contained" disabled={busy} onClick={handleSaveOrgaSettings}>
Einstellungen speichern
</Button>
</DialogActions>
</Dialog>
<Container maxWidth={false} sx={{ width: "100%", maxWidth: "none" }}>
<Stack spacing={3} px={2}>
{message ? <Alert severity={message.type} sx={{ whiteSpace: "pre-line" }}>{message.text}</Alert> : null}
{periodOverviewCard}
{isCompactLayout ? (
<Stack spacing={3}>
<Card>
<Tabs
value={mobileSection}
onChange={(_, nextValue: MobileSection) => setMobileSection(nextValue)}
variant="fullWidth"
>
<Tab value="overview" label={"AG-\u00dcbersicht"} />
<Tab value="finance" label={"Finanz\u00fcbersicht"} />
<Tab value="actions" label="Aktionen" />
</Tabs>
</Card>
{mobileSection === "overview" ? overviewContent : mobileSection === "finance" ? financeOverviewContent : actionCards}
</Stack>
) : (
<Box sx={{ width: "100%" }}>{desktopSectionContent}</Box>
)}
</Stack>
</Container>
</Box>
);
}