2684 lines
98 KiB
TypeScript
2684 lines
98 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,
|
|
Checkbox,
|
|
Chip,
|
|
Container,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
FormControlLabel,
|
|
IconButton,
|
|
MenuItem,
|
|
Stack,
|
|
Tab,
|
|
Tabs,
|
|
TextField,
|
|
Typography,
|
|
useMediaQuery
|
|
} from "@mui/material";
|
|
import { alpha, useTheme } from "@mui/material/styles";
|
|
import { signOut } from "next-auth/react";
|
|
import { useRouter } from "next/navigation";
|
|
import type { FormEvent } from "react";
|
|
import { startTransition, useEffect, useMemo, useState } from "react";
|
|
|
|
import { BudgetColumn } from "@/components/dashboard/budget-column";
|
|
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
|
import type {
|
|
DashboardAccountingPeriod,
|
|
DashboardAuditLog,
|
|
DashboardManagedUser,
|
|
DashboardSettings,
|
|
DashboardViewer,
|
|
DashboardWorkingGroup
|
|
} from "@/lib/dashboard-types";
|
|
import {
|
|
APPROVAL_FLOW,
|
|
approvalLabel,
|
|
canManageBudgets,
|
|
canManageUsers,
|
|
getAvailableApprovalRoles,
|
|
roleLabel
|
|
} from "@/lib/domain";
|
|
|
|
type DashboardShellProps = {
|
|
viewer: DashboardViewer;
|
|
workingGroups: DashboardWorkingGroup[];
|
|
managedUsers: DashboardManagedUser[];
|
|
auditLogs: DashboardAuditLog[];
|
|
accountingPeriods: DashboardAccountingPeriod[];
|
|
currentPeriodId: string;
|
|
settings: DashboardSettings;
|
|
};
|
|
|
|
type ExpenseFormState = {
|
|
title: string;
|
|
description: string;
|
|
amount: string;
|
|
agId: string;
|
|
budgetId: string;
|
|
recurrence: "NONE" | "MONTHLY";
|
|
recurrenceStartAt: string;
|
|
};
|
|
|
|
type BudgetFormState = {
|
|
workingGroupId: string;
|
|
name: string;
|
|
totalBudget: string;
|
|
colorCode: string;
|
|
};
|
|
|
|
type BudgetReleaseFormState = {
|
|
workingGroupId: string;
|
|
budgetId: string;
|
|
releasedAmount: string;
|
|
};
|
|
|
|
type WorkingGroupFormState = {
|
|
name: string;
|
|
};
|
|
|
|
type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number];
|
|
|
|
type UserFormState = {
|
|
username: string;
|
|
password: string;
|
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
|
workingGroupId: string;
|
|
};
|
|
|
|
type ManagedUserDraft = {
|
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
|
workingGroupId: string;
|
|
};
|
|
|
|
type PeriodFormState = {
|
|
name: string;
|
|
startsAt: string;
|
|
endsAt: string;
|
|
copyBudgetsFromPeriodId: string;
|
|
};
|
|
|
|
type PeriodEditFormState = {
|
|
name: string;
|
|
startsAt: string;
|
|
endsAt: string;
|
|
};
|
|
|
|
type OrgaSettingsDraft = {
|
|
requiredApprovalTypes: ApprovalPermissionValue[];
|
|
budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
|
|
};
|
|
|
|
type DashboardMessage = {
|
|
type: "success" | "error";
|
|
text: string;
|
|
};
|
|
|
|
function sortApprovalPermissions(value: ApprovalPermissionValue[]) {
|
|
return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType));
|
|
}
|
|
|
|
function toggleApprovalPermission(
|
|
currentValue: ApprovalPermissionValue[],
|
|
approvalType: ApprovalPermissionValue
|
|
) {
|
|
return currentValue.includes(approvalType)
|
|
? currentValue.filter((entry) => entry !== approvalType)
|
|
: sortApprovalPermissions([...currentValue, approvalType]);
|
|
}
|
|
function sortManagedUsersList(users: DashboardManagedUser[]) {
|
|
const roleOrder: Record<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" | "actions";
|
|
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
|
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|
style: "currency",
|
|
currency: "EUR"
|
|
});
|
|
|
|
const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short"
|
|
});
|
|
|
|
function toDateInputValue(value: string) {
|
|
return value.slice(0, 10);
|
|
}
|
|
|
|
function formatPeriodRange(startsAt: string, endsAt: string) {
|
|
const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
|
return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`;
|
|
}
|
|
|
|
function getSuggestedPeriodDraft(currentPeriod: DashboardAccountingPeriod | undefined): PeriodFormState {
|
|
if (!currentPeriod) {
|
|
const year = new Date().getFullYear();
|
|
return {
|
|
name: `Haushalt ${year}`,
|
|
startsAt: toDateInputValue(new Date(Date.UTC(year, 0, 1)).toISOString()),
|
|
endsAt: toDateInputValue(new Date(Date.UTC(year, 11, 31)).toISOString()),
|
|
copyBudgetsFromPeriodId: ""
|
|
};
|
|
}
|
|
|
|
const startsAt = new Date(currentPeriod.startsAt);
|
|
const endsAt = new Date(currentPeriod.endsAt);
|
|
const duration = Math.max(endsAt.getTime() - startsAt.getTime(), 24 * 60 * 60 * 1000);
|
|
const nextStart = new Date(endsAt.getTime() + 24 * 60 * 60 * 1000);
|
|
const nextEnd = new Date(nextStart.getTime() + duration);
|
|
|
|
return {
|
|
name: `${currentPeriod.name} Folgezeitraum`,
|
|
startsAt: toDateInputValue(nextStart.toISOString()),
|
|
endsAt: toDateInputValue(nextEnd.toISOString()),
|
|
copyBudgetsFromPeriodId: currentPeriod.id
|
|
};
|
|
}
|
|
|
|
function getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined): PeriodEditFormState {
|
|
if (!period) {
|
|
return {
|
|
name: "",
|
|
startsAt: "",
|
|
endsAt: ""
|
|
};
|
|
}
|
|
|
|
return {
|
|
name: period.name,
|
|
startsAt: toDateInputValue(period.startsAt),
|
|
endsAt: toDateInputValue(period.endsAt)
|
|
};
|
|
}
|
|
|
|
function generatePassword(length = 14) {
|
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%";
|
|
const cryptoSource = globalThis.crypto;
|
|
|
|
if (!cryptoSource?.getRandomValues) {
|
|
return "RFP2026!Start";
|
|
}
|
|
|
|
const values = cryptoSource.getRandomValues(new Uint32Array(length));
|
|
return Array.from(values, (value) => alphabet[value % alphabet.length]).join("");
|
|
}
|
|
|
|
async function parseResponse(response: Response) {
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden.");
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function urlBase64ToUint8Array(value: string) {
|
|
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
|
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
|
const rawData = window.atob(base64);
|
|
return Uint8Array.from([...rawData], (character) => character.charCodeAt(0));
|
|
}
|
|
|
|
export function DashboardShell({
|
|
viewer,
|
|
workingGroups,
|
|
managedUsers,
|
|
auditLogs,
|
|
accountingPeriods,
|
|
currentPeriodId,
|
|
settings
|
|
}: DashboardShellProps) {
|
|
const theme = useTheme();
|
|
const isDark = theme.palette.mode === "dark";
|
|
const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg"));
|
|
const router = useRouter();
|
|
|
|
const visibleGroups = workingGroups;
|
|
const editableExpenseGroups =
|
|
viewer.role === "MEMBER"
|
|
? workingGroups.filter((group) => group.id === viewer.workingGroupId)
|
|
: workingGroups;
|
|
const canManageAccounts = canManageUsers(viewer.role);
|
|
const canManagePeriods = canManageBudgets(viewer.role);
|
|
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
|
const approvalThreshold = settings.approvalThreshold;
|
|
const desktopSections = [
|
|
{ value: "overview" as const, label: "\u00dcbersicht" },
|
|
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
|
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
|
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
|
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
|
|
];
|
|
const showDesktopSectionTabs = !isCompactLayout && desktopSections.length > 1;
|
|
|
|
const defaultEditableGroup =
|
|
editableExpenseGroups.find((group) => group.id === viewer.workingGroupId) ??
|
|
editableExpenseGroups[0] ??
|
|
visibleGroups[0];
|
|
const defaultBudget = defaultEditableGroup?.budgets[0];
|
|
|
|
const [expenseForm, setExpenseForm] = useState<ExpenseFormState>({
|
|
title: "",
|
|
description: "",
|
|
amount: "",
|
|
agId: defaultEditableGroup?.id ?? "",
|
|
budgetId: defaultBudget?.id ?? "",
|
|
recurrence: "NONE",
|
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
|
});
|
|
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: visibleGroups[0]?.id ?? ""
|
|
});
|
|
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
|
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
|
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
|
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 [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 [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
|
useEffect(() => {
|
|
setSelectedCurrentPeriodId(currentPeriodId);
|
|
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
|
}, [currentPeriod, currentPeriodId]);
|
|
|
|
useEffect(() => {
|
|
const selectedPeriod = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
|
|
setPeriodEditForm(getPeriodEditDraft(selectedPeriod));
|
|
}, [accountingPeriods, currentPeriod, selectedCurrentPeriodId]);
|
|
|
|
useEffect(() => {
|
|
if (!desktopSections.some((section) => section.value === desktopSection)) {
|
|
setDesktopSection("overview");
|
|
}
|
|
}, [desktopSection, desktopSections]);
|
|
|
|
useEffect(() => {
|
|
if (visibleGroups.length === 0) {
|
|
setBudgetForm((current) => ({
|
|
...current,
|
|
workingGroupId: ""
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const groupStillExists = visibleGroups.some((group) => group.id === budgetForm.workingGroupId);
|
|
|
|
if (!groupStillExists) {
|
|
setBudgetForm((current) => ({
|
|
...current,
|
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [budgetForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
if (visibleGroups.length === 0) {
|
|
setBudgetReleaseForm({
|
|
workingGroupId: "",
|
|
budgetId: "",
|
|
releasedAmount: "0.00"
|
|
});
|
|
return;
|
|
}
|
|
|
|
const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0];
|
|
const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId) ?? selectedGroup?.budgets[0];
|
|
|
|
if (
|
|
budgetReleaseForm.workingGroupId !== (selectedGroup?.id ?? "") ||
|
|
budgetReleaseForm.budgetId !== (selectedBudget?.id ?? "")
|
|
) {
|
|
setBudgetReleaseForm({
|
|
workingGroupId: selectedGroup?.id ?? "",
|
|
budgetId: selectedBudget?.id ?? "",
|
|
releasedAmount: (selectedBudget?.releasedAmount ?? 0).toFixed(2)
|
|
});
|
|
}
|
|
}, [budgetReleaseForm.budgetId, budgetReleaseForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
if (!budgetReleaseForm.budgetId) {
|
|
return;
|
|
}
|
|
|
|
const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0];
|
|
const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId);
|
|
|
|
if (!selectedBudget) {
|
|
return;
|
|
}
|
|
|
|
const nextReleasedAmount = selectedBudget.releasedAmount.toFixed(2);
|
|
if (budgetReleaseForm.releasedAmount !== nextReleasedAmount) {
|
|
setBudgetReleaseForm((current) => ({
|
|
...current,
|
|
releasedAmount: nextReleasedAmount
|
|
}));
|
|
}
|
|
}, [budgetReleaseForm.budgetId, budgetReleaseForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
if (!message || message.type !== "success") {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setMessage((current) => (current?.type === "success" ? null : current));
|
|
}, 10000);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [message]);
|
|
|
|
useEffect(() => {
|
|
const hasEditableGroup = editableExpenseGroups.some((group) => group.id === expenseForm.agId);
|
|
|
|
if (!hasEditableGroup) {
|
|
setExpenseForm((current) => ({
|
|
...current,
|
|
agId: defaultEditableGroup?.id ?? "",
|
|
budgetId: defaultEditableGroup?.budgets[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId]);
|
|
|
|
useEffect(() => {
|
|
const selectedGroup =
|
|
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
|
const hasBudget = selectedGroup?.budgets.some((budget) => budget.id === expenseForm.budgetId) ?? false;
|
|
|
|
if (!hasBudget) {
|
|
setExpenseForm((current) => ({
|
|
...current,
|
|
budgetId: selectedGroup?.budgets[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
|
|
|
useEffect(() => {
|
|
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
|
|
|
if (!groupStillExists) {
|
|
setUserForm((current) => ({
|
|
...current,
|
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [userForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
|
|
}, [approvalThreshold]);
|
|
|
|
useEffect(() => {
|
|
setOrgaSettingsDraft({
|
|
requiredApprovalTypes: settings.requiredApprovalTypes,
|
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
|
});
|
|
}, [settings.budgetReleaseNotifyTarget, settings.requiredApprovalTypes]);
|
|
|
|
useEffect(() => {
|
|
setManagedUsersState(sortManagedUsersList(managedUsers));
|
|
}, [managedUsers]);
|
|
|
|
useEffect(() => {
|
|
if (editingUserId && !managedUsersState.some((user) => user.id === editingUserId)) {
|
|
setEditingUserId(null);
|
|
}
|
|
}, [editingUserId, managedUsersState]);
|
|
const selectedExpenseGroup =
|
|
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
|
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
|
const selectedBudgetWorkingGroup =
|
|
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
|
|
const selectedBudgetReleaseGroup =
|
|
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
|
const selectedBudgetReleaseOptions = selectedBudgetReleaseGroup?.budgets ?? [];
|
|
const selectedBudgetReleaseBudget =
|
|
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
|
|
selectedBudgetReleaseOptions[0] ??
|
|
null;
|
|
const selectedBudgetReleasePaidAmount =
|
|
selectedBudgetReleaseBudget?.expenses.reduce(
|
|
(sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0),
|
|
0
|
|
) ?? 0;
|
|
const selectedPeriodForManagement =
|
|
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
|
|
const periodEditDirty =
|
|
selectedPeriodForManagement !== null &&
|
|
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
|
|
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
|
|
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
|
|
|
|
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
|
return userDrafts[user.id] ?? {
|
|
role: user.role,
|
|
workingGroupId: user.workingGroupId ?? ""
|
|
};
|
|
}
|
|
|
|
function updateManagedUserDraft(user: DashboardManagedUser, patch: Partial<ManagedUserDraft>) {
|
|
setUserDrafts((current) => ({
|
|
...current,
|
|
[user.id]: {
|
|
...getManagedUserDraft(user),
|
|
...patch
|
|
}
|
|
}));
|
|
}
|
|
|
|
function resetManagedUserDraft(user: DashboardManagedUser) {
|
|
setUserDrafts((current) => ({
|
|
...current,
|
|
[user.id]: {
|
|
role: user.role,
|
|
workingGroupId: user.workingGroupId ?? ""
|
|
}
|
|
}));
|
|
}
|
|
const totals = useMemo(() => {
|
|
return visibleGroups.reduce(
|
|
(summary, group) => {
|
|
const approved = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const pending = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const subscriptions = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.recurrence === "MONTHLY" ? expense.amount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const subscriptionCount = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum + budget.expenses.filter((expense) => expense.recurrence === "MONTHLY").length,
|
|
0
|
|
);
|
|
|
|
summary.budget += group.totalBudget;
|
|
summary.approved += approved;
|
|
summary.pending += pending;
|
|
summary.subscriptions += subscriptions;
|
|
summary.subscriptionCount += subscriptionCount;
|
|
|
|
return summary;
|
|
},
|
|
{ budget: 0, approved: 0, pending: 0, subscriptions: 0, subscriptionCount: 0 }
|
|
);
|
|
}, [visibleGroups]);
|
|
|
|
async function runAction<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 : ""
|
|
})
|
|
})
|
|
);
|
|
|
|
const resetGroup = defaultEditableGroup?.id ?? "";
|
|
const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? "";
|
|
|
|
setExpenseForm({
|
|
title: "",
|
|
description: "",
|
|
amount: "",
|
|
agId: resetGroup,
|
|
budgetId: resetBudget,
|
|
recurrence: "NONE",
|
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
|
});
|
|
}, "Ausgabe wurde gespeichert.");
|
|
}
|
|
|
|
async function handleUpsertBudget(event: FormEvent<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 handleDeleteBudget(budgetId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/budgets/${budgetId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, "Budget wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleDeleteExpense(expenseId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/expenses/${expenseId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, "Ausgabe wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleCreatePeriod(event: FormEvent<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(periodEditForm)
|
|
})
|
|
);
|
|
}, `Zeitraum ${periodEditForm.name.trim() || selectedPeriodForManagement.name} wurde aktualisiert.`);
|
|
}
|
|
|
|
async function handleDeletePeriod(periodId: string, periodName: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/periods/${periodId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, `Zeitraum ${periodName} wurde gelöscht.`);
|
|
}
|
|
|
|
async function handleSetCurrentPeriod() {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch("/api/periods/current", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
periodId: selectedCurrentPeriodId
|
|
})
|
|
})
|
|
);
|
|
}, "Aktuelle \u00dcbersicht wurde auf den gew\u00e4hlten Zeitraum umgestellt.");
|
|
}
|
|
|
|
async function handleSaveWorkingGroup(groupId: string, name: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/working-groups/${groupId}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({ name })
|
|
})
|
|
);
|
|
}, "AG wurde aktualisiert.");
|
|
}
|
|
|
|
async function handleDeleteWorkingGroup(groupId: string, groupName: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/working-groups/${groupId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
|
|
const nextGroup = visibleGroups.find((group) => group.id !== groupId) ?? null;
|
|
|
|
setBudgetForm((current) => ({
|
|
...current,
|
|
workingGroupId: nextGroup?.id ?? ""
|
|
}));
|
|
|
|
setExpenseForm((current) => ({
|
|
...current,
|
|
agId: current.agId === groupId ? nextGroup?.id ?? "" : current.agId,
|
|
budgetId: current.agId === groupId ? nextGroup?.budgets[0]?.id ?? "" : current.budgetId
|
|
}));
|
|
}, `AG ${groupName} wurde gelöscht.`);
|
|
}
|
|
|
|
async function handleCreateUser(event: FormEvent<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: visibleGroups[0]?.id ?? ""
|
|
});
|
|
|
|
return {
|
|
createdUsername,
|
|
createdPassword
|
|
};
|
|
},
|
|
({ createdUsername, createdPassword }) =>
|
|
`Nutzer wurde angelegt. Startpasswort für ${createdUsername}: ${createdPassword}`
|
|
);
|
|
}
|
|
|
|
async function handleUpdateUser(user: DashboardManagedUser) {
|
|
const draft = getManagedUserDraft(user);
|
|
|
|
await runAction(async () => {
|
|
const result = (await parseResponse(
|
|
await fetch(`/api/users/${user.id}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
role: draft.role,
|
|
workingGroupId: draft.workingGroupId
|
|
})
|
|
})
|
|
)) as { user?: DashboardManagedUser };
|
|
|
|
if (result.user) {
|
|
setManagedUsersState((current) =>
|
|
sortManagedUsersList([...current.filter((entry) => entry.id !== result.user!.id), result.user!])
|
|
);
|
|
}
|
|
|
|
setEditingUserId(null);
|
|
}, `Nutzer ${user.username} wurde aktualisiert.`);
|
|
}
|
|
|
|
async function handleSaveApprovalThreshold() {
|
|
const nextThreshold = Number(approvalThresholdDraft.replace(",", "."));
|
|
|
|
if (!Number.isFinite(nextThreshold) || nextThreshold < 0) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte eine gueltige Freigabe-Schwelle eingeben."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
approvalThreshold: nextThreshold
|
|
})
|
|
})
|
|
);
|
|
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
|
}
|
|
|
|
async function handleSaveOrgaSettings() {
|
|
if (orgaSettingsDraft.requiredApprovalTypes.length === 0) {
|
|
setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle ausw\u00e4hlen." });
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(orgaSettingsDraft)
|
|
})
|
|
);
|
|
|
|
setIsOrgaSettingsOpen(false);
|
|
}, "Zust\u00e4ndigkeiten und Benachrichtigungen wurden gespeichert.");
|
|
}
|
|
|
|
async function handleEnablePushNotifications() {
|
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
|
|
setPushStatus("unsupported");
|
|
setMessage({ type: "error", text: "Dieser Browser unterstuetzt Web Push nicht." });
|
|
return;
|
|
}
|
|
|
|
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
|
if (!publicKey) {
|
|
setMessage({ type: "error", text: "VAPID Public Key ist nicht konfiguriert." });
|
|
return;
|
|
}
|
|
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== "granted") {
|
|
setPushStatus("blocked");
|
|
setMessage({ type: "error", text: "Benachrichtigungen wurden nicht erlaubt." });
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const existingSubscription = await registration.pushManager.getSubscription();
|
|
const subscription =
|
|
existingSubscription ??
|
|
(await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
}));
|
|
|
|
await parseResponse(
|
|
await fetch("/api/push-subscriptions", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(subscription.toJSON())
|
|
})
|
|
);
|
|
|
|
setPushStatus("enabled");
|
|
}, "Web Push ist für dieses Gerät aktiviert.");
|
|
}
|
|
|
|
async function handleDeleteUser(userId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/users/${userId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
|
|
setManagedUsersState((current) => current.filter((user) => user.id !== userId));
|
|
setUserDrafts((current) => {
|
|
const next = { ...current };
|
|
delete next[userId];
|
|
return next;
|
|
});
|
|
setPasswordDrafts((current) => {
|
|
const next = { ...current };
|
|
delete next[userId];
|
|
return next;
|
|
});
|
|
}, "Nutzer wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleResetPassword(userId: string, userName: string) {
|
|
const nextPassword = passwordDrafts[userId]?.trim() ?? "";
|
|
|
|
if (nextPassword.length < 8) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte ein neues Passwort mit mindestens 8 Zeichen eingeben."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(
|
|
async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/users/${userId}/password`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
password: nextPassword
|
|
})
|
|
})
|
|
);
|
|
|
|
setEditingPasswordUserId(null);
|
|
|
|
return {
|
|
userName,
|
|
nextPassword
|
|
};
|
|
},
|
|
({ userName: changedUserName, nextPassword: changedPassword }) =>
|
|
`Neues Passwort f\u00fcr ${changedUserName}: ${changedPassword}`
|
|
);
|
|
}
|
|
|
|
async function handleImportBackup() {
|
|
if (!backupFile) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte zuerst eine CSV-Datei auswählen."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(
|
|
async () => {
|
|
const formData = new FormData();
|
|
formData.set("file", backupFile);
|
|
|
|
const result = await parseResponse(
|
|
await fetch("/api/import/csv", {
|
|
method: "POST",
|
|
body: formData
|
|
})
|
|
);
|
|
|
|
setBackupFile(null);
|
|
return result as { importedRows?: number };
|
|
},
|
|
(result) =>
|
|
`Backup wurde eingespielt.${typeof result.importedRows === "number" ? ` Importierte Zeilen: ${result.importedRows}.` : ""}`
|
|
);
|
|
}
|
|
|
|
async function handleRestoreAuditLog(entryId: string, summary: string) {
|
|
if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) {
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/audit-logs/${entryId}/restore`, {
|
|
method: "POST"
|
|
})
|
|
);
|
|
}, "Änderung wurde zurückgesetzt.");
|
|
}
|
|
|
|
function openPasswordReset(userId: string) {
|
|
setEditingPasswordUserId(userId);
|
|
setPasswordDrafts((current) => ({
|
|
...current,
|
|
[userId]: current[userId] && current[userId].length >= 8 ? current[userId] : generatePassword()
|
|
}));
|
|
}
|
|
|
|
function openUserEditor(user: DashboardManagedUser) {
|
|
resetManagedUserDraft(user);
|
|
setEditingUserId(user.id);
|
|
}
|
|
|
|
function renderApprovalPermissionSelector(
|
|
value: ApprovalPermissionValue[],
|
|
onToggle: (approvalType: ApprovalPermissionValue) => void,
|
|
helperText: string,
|
|
availableApprovalTypes: ApprovalPermissionValue[]
|
|
) {
|
|
return (
|
|
<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 verfuegbar
|
|
</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 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 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 || 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>
|
|
|
|
<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="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 || 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}
|
|
|
|
{canManageBudgets(viewer.role) && (isCompactLayout || 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 || 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 ? (
|
|
<Card sx={islandCardSx}>
|
|
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{canManageAccounts && (isCompactLayout || 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 || desktopSection === "users") ? (
|
|
<Stack spacing={3}>
|
|
<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">AG-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}
|
|
required={userForm.role === "MEMBER"}
|
|
helperText={
|
|
visibleGroups.length === 0
|
|
? "Lege zuerst eine AG an."
|
|
: userForm.role === "MEMBER"
|
|
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
|
}
|
|
>
|
|
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
|
{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>
|
|
|
|
<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
|
|
/>
|
|
<Button type="button" variant="outlined" disabled={busy} onClick={handleSaveApprovalThreshold}>
|
|
Schwelle speichern
|
|
</Button>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
</Stack>
|
|
) : null}
|
|
{canManageAccounts && (isCompactLayout || 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">AG-Mitglied</MenuItem>
|
|
</TextField>
|
|
<TextField
|
|
select
|
|
label="AG-Zuordnung"
|
|
value={draft.workingGroupId}
|
|
onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })}
|
|
fullWidth
|
|
disabled={visibleGroups.length === 0}
|
|
required={draft.role === "MEMBER"}
|
|
helperText={
|
|
visibleGroups.length === 0
|
|
? "Lege zuerst eine AG an."
|
|
: draft.role === "MEMBER"
|
|
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
|
}
|
|
>
|
|
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
|
{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 || 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 overviewContent = (
|
|
<Stack spacing={2.5}>
|
|
{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: "max-content", minWidth: "100%", alignItems: "stretch" }}>
|
|
{visibleGroups.map((group) => (
|
|
<Box key={group.id} sx={{ width: "min(88vw, 456px)", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
|
<BudgetColumn
|
|
group={group}
|
|
viewer={viewer}
|
|
busy={busy}
|
|
approvalThreshold={approvalThreshold}
|
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
|
onApprove={handleApprove}
|
|
onMarkPaid={handleMarkPaid}
|
|
onDocument={handleDocument}
|
|
onUploadProof={handleUploadProof}
|
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
|
onSaveBudget={handleSaveBudget}
|
|
onDeleteBudget={handleDeleteBudget}
|
|
onDeleteExpense={handleDeleteExpense}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</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
|
|
}}
|
|
>
|
|
{visibleGroups.map((group) => (
|
|
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
|
<BudgetColumn
|
|
group={group}
|
|
viewer={viewer}
|
|
busy={busy}
|
|
approvalThreshold={approvalThreshold}
|
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
|
onApprove={handleApprove}
|
|
onMarkPaid={handleMarkPaid}
|
|
onDocument={handleDocument}
|
|
onUploadProof={handleUploadProof}
|
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
|
onSaveBudget={handleSaveBudget}
|
|
onDeleteBudget={handleDeleteBudget}
|
|
onDeleteExpense={handleDeleteExpense}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</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 === "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}
|
|
</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={{ maxWidth: 1640 }}>
|
|
<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 } }}>
|
|
<Stack spacing={3}>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
justifyContent="space-between"
|
|
alignItems={{ xs: "flex-start", md: "center" }}
|
|
gap={2}
|
|
>
|
|
<Box sx={{ maxWidth: 760 }}>
|
|
<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}
|
|
{viewer.role === "ORGA" ? (
|
|
<IconButton
|
|
aria-label="Zuständigkeiten und Benachrichtigungen"
|
|
sx={{ border: `1px solid ${alpha("#FFFFFF", 0.28)}`, color: "white" }}
|
|
onClick={() => setIsOrgaSettingsOpen(true)}
|
|
>
|
|
<SettingsRoundedIcon />
|
|
</IconButton>
|
|
) : 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" }}
|
|
/>
|
|
<Chip
|
|
icon={<WalletRoundedIcon />}
|
|
label={`Geplant: ${currencyFormatter.format(totals.pending)}`}
|
|
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
|
/>
|
|
<Chip
|
|
icon={<SavingsRoundedIcon />}
|
|
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
|
|
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 }}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
Freigaberollen
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen.
|
|
</Typography>
|
|
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
|
{APPROVAL_FLOW.map((approvalType) => (
|
|
<FormControlLabel
|
|
key={approvalType}
|
|
control={
|
|
<Checkbox
|
|
checked={orgaSettingsDraft.requiredApprovalTypes.includes(approvalType)}
|
|
onChange={() =>
|
|
setOrgaSettingsDraft((current) => ({
|
|
...current,
|
|
requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType)
|
|
}))
|
|
}
|
|
/>
|
|
}
|
|
label={approvalLabel(approvalType)}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
<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 AG-Mitglieder</MenuItem>
|
|
</TextField>
|
|
</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={{ maxWidth: 1640 }}>
|
|
<Stack spacing={3} px={2}>
|
|
{message ? <Alert severity={message.type}>{message.text}</Alert> : null}
|
|
{periodOverviewCard}
|
|
|
|
{isCompactLayout ? (
|
|
<Stack spacing={3}>
|
|
<Card>
|
|
<Tabs
|
|
value={mobileSection}
|
|
onChange={(_, nextValue: MobileSection) => setMobileSection(nextValue)}
|
|
variant="fullWidth"
|
|
>
|
|
<Tab value="overview" label={"\u00dcbersicht"} />
|
|
<Tab value="actions" label="Aktionen" />
|
|
</Tabs>
|
|
</Card>
|
|
{mobileSection === "overview" ? overviewContent : actionCards}
|
|
</Stack>
|
|
) : (
|
|
<Box sx={{ width: "100%" }}>{desktopSectionContent}</Box>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
</Box>
|
|
);
|
|
}
|