2094 lines
76 KiB
TypeScript
2094 lines
76 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 SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
|
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
|
|
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
Container,
|
|
MenuItem,
|
|
Stack,
|
|
Tab,
|
|
Tabs,
|
|
TextField,
|
|
Typography,
|
|
useMediaQuery
|
|
} from "@mui/material";
|
|
import { alpha, useTheme } from "@mui/material/styles";
|
|
import { signOut } from "next-auth/react";
|
|
import { useRouter } from "next/navigation";
|
|
import type { FormEvent } from "react";
|
|
import { startTransition, useEffect, useMemo, useState } from "react";
|
|
|
|
import { BudgetColumn } from "@/components/dashboard/budget-column";
|
|
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
|
import type {
|
|
DashboardAccountingPeriod,
|
|
DashboardAuditLog,
|
|
DashboardManagedUser,
|
|
DashboardViewer,
|
|
DashboardWorkingGroup
|
|
} from "@/lib/dashboard-types";
|
|
import {
|
|
APPROVAL_FLOW,
|
|
approvalLabel,
|
|
canManageBudgets,
|
|
canManageUsers,
|
|
getDefaultApprovalPermissionsForRole,
|
|
roleLabel
|
|
} from "@/lib/domain";
|
|
|
|
type DashboardShellProps = {
|
|
viewer: DashboardViewer;
|
|
workingGroups: DashboardWorkingGroup[];
|
|
managedUsers: DashboardManagedUser[];
|
|
auditLogs: DashboardAuditLog[];
|
|
accountingPeriods: DashboardAccountingPeriod[];
|
|
currentPeriodId: string;
|
|
approvalThreshold: number;
|
|
};
|
|
|
|
type ExpenseFormState = {
|
|
title: string;
|
|
description: string;
|
|
amount: string;
|
|
agId: string;
|
|
budgetId: string;
|
|
recurrence: "NONE" | "MONTHLY";
|
|
proofUrl: string;
|
|
};
|
|
|
|
type BudgetFormState = {
|
|
workingGroupId: string;
|
|
name: string;
|
|
totalBudget: string;
|
|
colorCode: string;
|
|
};
|
|
|
|
type WorkingGroupFormState = {
|
|
name: string;
|
|
};
|
|
|
|
type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number];
|
|
|
|
type UserFormState = {
|
|
username: string;
|
|
password: string;
|
|
role: "ADMIN" | "FINANCE" | "MEMBER";
|
|
workingGroupId: string;
|
|
approvalPermissions: ApprovalPermissionValue[];
|
|
};
|
|
|
|
type ManagedUserDraft = {
|
|
role: "ADMIN" | "FINANCE" | "MEMBER";
|
|
workingGroupId: string;
|
|
approvalPermissions: ApprovalPermissionValue[];
|
|
};
|
|
|
|
type PeriodFormState = {
|
|
name: string;
|
|
startsAt: string;
|
|
endsAt: string;
|
|
copyBudgetsFromPeriodId: string;
|
|
};
|
|
|
|
type DashboardMessage = {
|
|
type: "success" | "error";
|
|
text: string;
|
|
};
|
|
|
|
function sortApprovalPermissions(value: ApprovalPermissionValue[]) {
|
|
return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType));
|
|
}
|
|
|
|
function toggleApprovalPermission(
|
|
currentValue: ApprovalPermissionValue[],
|
|
approvalType: ApprovalPermissionValue
|
|
) {
|
|
return currentValue.includes(approvalType)
|
|
? currentValue.filter((entry) => entry !== approvalType)
|
|
: sortApprovalPermissions([...currentValue, approvalType]);
|
|
}
|
|
|
|
type MobileSection = "overview" | "actions";
|
|
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
|
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|
style: "currency",
|
|
currency: "EUR"
|
|
});
|
|
|
|
const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short"
|
|
});
|
|
|
|
function toDateInputValue(value: string) {
|
|
return value.slice(0, 10);
|
|
}
|
|
|
|
function formatPeriodRange(startsAt: string, endsAt: string) {
|
|
const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
|
return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`;
|
|
}
|
|
|
|
function getSuggestedPeriodDraft(currentPeriod: DashboardAccountingPeriod | undefined): PeriodFormState {
|
|
if (!currentPeriod) {
|
|
const year = new Date().getFullYear();
|
|
return {
|
|
name: `Haushalt ${year}`,
|
|
startsAt: toDateInputValue(new Date(Date.UTC(year, 0, 1)).toISOString()),
|
|
endsAt: toDateInputValue(new Date(Date.UTC(year, 11, 31)).toISOString()),
|
|
copyBudgetsFromPeriodId: ""
|
|
};
|
|
}
|
|
|
|
const startsAt = new Date(currentPeriod.startsAt);
|
|
const endsAt = new Date(currentPeriod.endsAt);
|
|
const duration = Math.max(endsAt.getTime() - startsAt.getTime(), 24 * 60 * 60 * 1000);
|
|
const nextStart = new Date(endsAt.getTime() + 24 * 60 * 60 * 1000);
|
|
const nextEnd = new Date(nextStart.getTime() + duration);
|
|
|
|
return {
|
|
name: `${currentPeriod.name} Folgezeitraum`,
|
|
startsAt: toDateInputValue(nextStart.toISOString()),
|
|
endsAt: toDateInputValue(nextEnd.toISOString()),
|
|
copyBudgetsFromPeriodId: currentPeriod.id
|
|
};
|
|
}
|
|
|
|
function generatePassword(length = 14) {
|
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%";
|
|
const cryptoSource = globalThis.crypto;
|
|
|
|
if (!cryptoSource?.getRandomValues) {
|
|
return "RFP2026!Start";
|
|
}
|
|
|
|
const values = cryptoSource.getRandomValues(new Uint32Array(length));
|
|
return Array.from(values, (value) => alphabet[value % alphabet.length]).join("");
|
|
}
|
|
|
|
async function parseResponse(response: Response) {
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden.");
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
export function DashboardShell({
|
|
viewer,
|
|
workingGroups,
|
|
managedUsers,
|
|
auditLogs,
|
|
accountingPeriods,
|
|
currentPeriodId,
|
|
approvalThreshold
|
|
}: DashboardShellProps) {
|
|
const theme = useTheme();
|
|
const isDark = theme.palette.mode === "dark";
|
|
const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg"));
|
|
const router = useRouter();
|
|
|
|
const visibleGroups = workingGroups;
|
|
const editableExpenseGroups =
|
|
viewer.role === "MEMBER"
|
|
? workingGroups.filter((group) => group.id === viewer.workingGroupId)
|
|
: workingGroups;
|
|
const canManageAccounts = canManageUsers(viewer.role);
|
|
const canManagePeriods = canManageBudgets(viewer.role);
|
|
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
|
const desktopSections = [
|
|
{ value: "overview" as const, label: "\u00dcbersicht" },
|
|
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
|
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
|
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
|
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
|
|
];
|
|
const showDesktopSectionTabs = !isCompactLayout && desktopSections.length > 1;
|
|
|
|
const defaultEditableGroup =
|
|
editableExpenseGroups.find((group) => group.id === viewer.workingGroupId) ??
|
|
editableExpenseGroups[0] ??
|
|
visibleGroups[0];
|
|
const defaultBudget = defaultEditableGroup?.budgets[0];
|
|
|
|
const [expenseForm, setExpenseForm] = useState<ExpenseFormState>({
|
|
title: "",
|
|
description: "",
|
|
amount: "",
|
|
agId: defaultEditableGroup?.id ?? "",
|
|
budgetId: defaultBudget?.id ?? "",
|
|
recurrence: "NONE",
|
|
proofUrl: ""
|
|
});
|
|
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
|
name: "Hauptbudget",
|
|
totalBudget: "1200",
|
|
colorCode: "#FFB94A"
|
|
});
|
|
const [workingGroupForm, setWorkingGroupForm] = useState<WorkingGroupFormState>({
|
|
name: ""
|
|
});
|
|
const [userForm, setUserForm] = useState<UserFormState>({
|
|
username: "",
|
|
password: "",
|
|
role: "MEMBER",
|
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
|
approvalPermissions: []
|
|
});
|
|
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 [selectedMobileGroupId, setSelectedMobileGroupId] = useState(
|
|
viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""
|
|
);
|
|
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 [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
|
|
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
|
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
|
useEffect(() => {
|
|
if (visibleGroups.length === 0) {
|
|
setSelectedMobileGroupId("");
|
|
return;
|
|
}
|
|
|
|
const hasSelectedGroup = visibleGroups.some((group) => group.id === selectedMobileGroupId);
|
|
if (!hasSelectedGroup) {
|
|
setSelectedMobileGroupId(viewer.workingGroupId ?? visibleGroups[0]?.id ?? "");
|
|
}
|
|
}, [selectedMobileGroupId, viewer.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
setSelectedCurrentPeriodId(currentPeriodId);
|
|
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
|
}, [currentPeriod, currentPeriodId]);
|
|
|
|
useEffect(() => {
|
|
if (!desktopSections.some((section) => section.value === desktopSection)) {
|
|
setDesktopSection("overview");
|
|
}
|
|
}, [desktopSection, desktopSections]);
|
|
|
|
useEffect(() => {
|
|
if (visibleGroups.length === 0) {
|
|
setBudgetForm((current) => ({
|
|
...current,
|
|
workingGroupId: ""
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const groupStillExists = visibleGroups.some((group) => group.id === budgetForm.workingGroupId);
|
|
|
|
if (!groupStillExists) {
|
|
setBudgetForm((current) => ({
|
|
...current,
|
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [budgetForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
if (!message || message.type !== "success") {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setMessage((current) => (current?.type === "success" ? null : current));
|
|
}, 10000);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [message]);
|
|
|
|
useEffect(() => {
|
|
const hasEditableGroup = editableExpenseGroups.some((group) => group.id === expenseForm.agId);
|
|
|
|
if (!hasEditableGroup) {
|
|
setExpenseForm((current) => ({
|
|
...current,
|
|
agId: defaultEditableGroup?.id ?? "",
|
|
budgetId: defaultEditableGroup?.budgets[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId]);
|
|
|
|
useEffect(() => {
|
|
const selectedGroup =
|
|
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
|
const hasBudget = selectedGroup?.budgets.some((budget) => budget.id === expenseForm.budgetId) ?? false;
|
|
|
|
if (!hasBudget) {
|
|
setExpenseForm((current) => ({
|
|
...current,
|
|
budgetId: selectedGroup?.budgets[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
|
|
|
useEffect(() => {
|
|
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
|
|
|
if (!groupStillExists) {
|
|
setUserForm((current) => ({
|
|
...current,
|
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
|
}));
|
|
}
|
|
}, [userForm.workingGroupId, visibleGroups]);
|
|
|
|
useEffect(() => {
|
|
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
|
|
}, [approvalThreshold]);
|
|
|
|
useEffect(() => {
|
|
if (editingUserId && !managedUsers.some((user) => user.id === editingUserId)) {
|
|
setEditingUserId(null);
|
|
}
|
|
}, [editingUserId, managedUsers]);
|
|
const selectedExpenseGroup =
|
|
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
|
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
|
const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0];
|
|
const selectedBudgetWorkingGroup =
|
|
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
|
|
const selectedPeriodForManagement =
|
|
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
|
|
|
|
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
|
return userDrafts[user.id] ?? {
|
|
role: user.role,
|
|
workingGroupId: user.workingGroupId ?? "",
|
|
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
|
};
|
|
}
|
|
|
|
function updateManagedUserDraft(user: DashboardManagedUser, patch: Partial<ManagedUserDraft>) {
|
|
setUserDrafts((current) => ({
|
|
...current,
|
|
[user.id]: {
|
|
...getManagedUserDraft(user),
|
|
...patch
|
|
}
|
|
}));
|
|
}
|
|
|
|
function resetManagedUserDraft(user: DashboardManagedUser) {
|
|
setUserDrafts((current) => ({
|
|
...current,
|
|
[user.id]: {
|
|
role: user.role,
|
|
workingGroupId: user.workingGroupId ?? "",
|
|
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
|
}
|
|
}));
|
|
}
|
|
const totals = useMemo(() => {
|
|
return visibleGroups.reduce(
|
|
(summary, group) => {
|
|
const approved = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const pending = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const subscriptions = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum +
|
|
budget.expenses.reduce(
|
|
(sum, expense) => sum + (expense.recurrence === "MONTHLY" ? expense.amount : 0),
|
|
0
|
|
),
|
|
0
|
|
);
|
|
const subscriptionCount = group.budgets.reduce(
|
|
(groupSum, budget) =>
|
|
groupSum + budget.expenses.filter((expense) => expense.recurrence === "MONTHLY").length,
|
|
0
|
|
);
|
|
|
|
summary.budget += group.totalBudget;
|
|
summary.approved += approved;
|
|
summary.pending += pending;
|
|
summary.subscriptions += subscriptions;
|
|
summary.subscriptionCount += subscriptionCount;
|
|
|
|
return summary;
|
|
},
|
|
{ budget: 0, approved: 0, pending: 0, subscriptions: 0, subscriptionCount: 0 }
|
|
);
|
|
}, [visibleGroups]);
|
|
|
|
async function runAction<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\u00fcr diese AG anlegen oder ausw\u00e4hlen." });
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch("/api/expenses", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
title: expenseForm.title,
|
|
description: expenseForm.description,
|
|
amount: expenseForm.amount,
|
|
agId: expenseForm.agId,
|
|
budgetId: expenseForm.budgetId,
|
|
recurrence: expenseForm.recurrence,
|
|
proofUrl: expenseForm.proofUrl
|
|
})
|
|
})
|
|
);
|
|
|
|
const resetGroup = defaultEditableGroup?.id ?? "";
|
|
const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? "";
|
|
|
|
setExpenseForm({
|
|
title: "",
|
|
description: "",
|
|
amount: "",
|
|
agId: resetGroup,
|
|
budgetId: resetBudget,
|
|
recurrence: "NONE",
|
|
proofUrl: ""
|
|
});
|
|
}, "Ausgabe wurde gespeichert.");
|
|
}
|
|
|
|
async function handleUpsertBudget(event: FormEvent<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 handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/budgets/${budgetId}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
name,
|
|
totalBudget,
|
|
colorCode
|
|
})
|
|
})
|
|
);
|
|
}, "Budget wurde aktualisiert.");
|
|
}
|
|
|
|
async function handleDeleteBudget(budgetId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/budgets/${budgetId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, "Budget wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleDeleteExpense(expenseId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/expenses/${expenseId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, "Ausgabe wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleCreatePeriod(event: FormEvent<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 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();
|
|
|
|
await parseResponse(
|
|
await fetch("/api/users", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
username: createdUsername,
|
|
password: userForm.password,
|
|
role: userForm.role,
|
|
workingGroupId: userForm.workingGroupId,
|
|
approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions)
|
|
})
|
|
})
|
|
);
|
|
|
|
setUserForm({
|
|
username: "",
|
|
password: "",
|
|
role: "MEMBER",
|
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
|
approvalPermissions: []
|
|
});
|
|
|
|
return {
|
|
createdUsername,
|
|
createdPassword
|
|
};
|
|
},
|
|
({ createdUsername, createdPassword }) =>
|
|
`Nutzer wurde angelegt. Startpasswort für ${createdUsername}: ${createdPassword}`
|
|
);
|
|
}
|
|
|
|
async function handleUpdateUser(user: DashboardManagedUser) {
|
|
const draft = getManagedUserDraft(user);
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/users/${user.id}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
role: draft.role,
|
|
workingGroupId: draft.workingGroupId,
|
|
approvalPermissions: sortApprovalPermissions(draft.approvalPermissions)
|
|
})
|
|
})
|
|
);
|
|
|
|
setEditingUserId(null);
|
|
}, `Nutzer ${user.username} wurde aktualisiert.`);
|
|
}
|
|
|
|
async function handleSaveApprovalThreshold() {
|
|
const nextThreshold = Number(approvalThresholdDraft.replace(",", "."));
|
|
|
|
if (!Number.isFinite(nextThreshold) || nextThreshold < 0) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte eine gueltige Freigabe-Schwelle eingeben."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
approvalThreshold: nextThreshold
|
|
})
|
|
})
|
|
);
|
|
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
|
}
|
|
async function handleDeleteUser(userId: string) {
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/users/${userId}`, {
|
|
method: "DELETE"
|
|
})
|
|
);
|
|
}, "Nutzer wurde gel\u00f6scht.");
|
|
}
|
|
|
|
async function handleResetPassword(userId: string, userName: string) {
|
|
const nextPassword = passwordDrafts[userId]?.trim() ?? "";
|
|
|
|
if (nextPassword.length < 8) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte ein neues Passwort mit mindestens 8 Zeichen eingeben."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(
|
|
async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/users/${userId}/password`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
password: nextPassword
|
|
})
|
|
})
|
|
);
|
|
|
|
setEditingPasswordUserId(null);
|
|
|
|
return {
|
|
userName,
|
|
nextPassword
|
|
};
|
|
},
|
|
({ userName: changedUserName, nextPassword: changedPassword }) =>
|
|
`Neues Passwort f\u00fcr ${changedUserName}: ${changedPassword}`
|
|
);
|
|
}
|
|
|
|
async function handleImportBackup() {
|
|
if (!backupFile) {
|
|
setMessage({
|
|
type: "error",
|
|
text: "Bitte zuerst eine CSV-Datei auswählen."
|
|
});
|
|
return;
|
|
}
|
|
|
|
await runAction(
|
|
async () => {
|
|
const formData = new FormData();
|
|
formData.set("file", backupFile);
|
|
|
|
const result = await parseResponse(
|
|
await fetch("/api/import/csv", {
|
|
method: "POST",
|
|
body: formData
|
|
})
|
|
);
|
|
|
|
setBackupFile(null);
|
|
return result as { importedRows?: number };
|
|
},
|
|
(result) =>
|
|
`Backup wurde eingespielt.${typeof result.importedRows === "number" ? ` Importierte Zeilen: ${result.importedRows}.` : ""}`
|
|
);
|
|
}
|
|
|
|
async function handleRestoreAuditLog(entryId: string, summary: string) {
|
|
if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) {
|
|
return;
|
|
}
|
|
|
|
await runAction(async () => {
|
|
await parseResponse(
|
|
await fetch(`/api/audit-logs/${entryId}/restore`, {
|
|
method: "POST"
|
|
})
|
|
);
|
|
}, "Änderung wurde zurückgesetzt.");
|
|
}
|
|
|
|
function openPasswordReset(userId: string) {
|
|
setEditingPasswordUserId(userId);
|
|
setPasswordDrafts((current) => ({
|
|
...current,
|
|
[userId]: current[userId] && current[userId].length >= 8 ? current[userId] : generatePassword()
|
|
}));
|
|
}
|
|
|
|
function openUserEditor(user: DashboardManagedUser) {
|
|
resetManagedUserDraft(user);
|
|
setEditingUserId(user.id);
|
|
}
|
|
|
|
function renderApprovalPermissionSelector(
|
|
value: ApprovalPermissionValue[],
|
|
onToggle: (approvalType: ApprovalPermissionValue) => void,
|
|
helperText: string
|
|
) {
|
|
return (
|
|
<Stack spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
Freigaberollen
|
|
</Typography>
|
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
|
{APPROVAL_FLOW.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>
|
|
);
|
|
})}
|
|
</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)}`,
|
|
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.82 : 0.96)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.04 : 0.02)} 100%)`,
|
|
overflow: "hidden",
|
|
boxShadow: isDark ? `0 20px 48px ${alpha("#02040A", 0.34)}` : `0 18px 44px ${alpha("#B86E2B", 0.08)}`
|
|
};
|
|
|
|
const nestedPanelSx = {
|
|
p: 2,
|
|
borderRadius: "20px",
|
|
border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`,
|
|
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.78 : 0.9)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.05 : 0.02)} 100%)`,
|
|
overflow: "hidden"
|
|
};
|
|
|
|
const periodManagementPanel = canManagePeriods ? (
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
Zeitraum wechseln
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{"Nur Vorstand und Finanz-AG 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={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>
|
|
<Typography color="text.secondary">
|
|
{"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "}
|
|
{approvalThreshold.toFixed(2)}
|
|
{" EUR wird automatisch freigegeben."}
|
|
</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 erscheinen oben gesammelt im \u00dcberblick."}
|
|
>
|
|
<MenuItem value="NONE">Einmalig</MenuItem>
|
|
<MenuItem value="MONTHLY">Monatliches Abo</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>
|
|
<TextField
|
|
label="Beleg-URL (optional)"
|
|
value={expenseForm.proofUrl}
|
|
onChange={(event) => setExpenseForm((current) => ({ ...current, proofUrl: event.target.value }))}
|
|
fullWidth
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="contained"
|
|
startIcon={<AddRoundedIcon />}
|
|
disabled={busy || selectedBudgetOptions.length === 0}
|
|
>
|
|
Ausgabe 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,
|
|
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
|
|
}));
|
|
}}
|
|
required
|
|
>
|
|
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
|
<MenuItem value="FINANCE">Finanz-AG</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: Auch Vorstand und Finanz-AG 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>
|
|
{renderApprovalPermissionSelector(
|
|
userForm.approvalPermissions,
|
|
(approvalType) =>
|
|
setUserForm((current) => ({
|
|
...current,
|
|
approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType)
|
|
})),
|
|
"Lege fest, für welche Freigabeschritte dieses Konto zeichnen darf."
|
|
)}
|
|
<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, Freigaberollen und Passwörter pflegen."}
|
|
</Typography>
|
|
</Box>
|
|
<Stack spacing={1.4}>
|
|
{managedUsers.map((user) => {
|
|
const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0;
|
|
const isResetOpen = editingPasswordUserId === user.id;
|
|
const isEditingUser = editingUserId === user.id;
|
|
const draft = getManagedUserDraft(user);
|
|
|
|
return (
|
|
<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),
|
|
backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06)
|
|
}}
|
|
>
|
|
<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,
|
|
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
|
|
});
|
|
}}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
|
<MenuItem value="FINANCE">Finanz-AG</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: Auch Vorstand und Finanz-AG 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>
|
|
{renderApprovalPermissionSelector(
|
|
draft.approvalPermissions,
|
|
(approvalType) =>
|
|
updateManagedUserDraft(user, {
|
|
approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType)
|
|
}),
|
|
"Lege fest, welche Freigabeschritte dieses Konto autorisieren darf."
|
|
)}
|
|
<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),
|
|
backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06)
|
|
}}
|
|
>
|
|
<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}>
|
|
{visibleGroups.length > 1 ? (
|
|
<Card>
|
|
<CardContent sx={{ p: 2.5 }}>
|
|
<Stack spacing={1.5}>
|
|
<Box>
|
|
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
|
|
AG auswählen
|
|
</Typography>
|
|
<Typography color="text.secondary">
|
|
Wähle die AG, die gerade in der Übersicht angezeigt werden soll.
|
|
</Typography>
|
|
</Box>
|
|
<TextField
|
|
select
|
|
label="Sichtbare AG"
|
|
value={selectedMobileGroupId}
|
|
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
|
|
fullWidth
|
|
>
|
|
{visibleGroups.map((group) => (
|
|
<MenuItem key={group.id} value={group.id}>
|
|
{group.name}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Stack direction="column" gap={2}>
|
|
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
|
|
<BudgetColumn
|
|
key={group.id}
|
|
group={group}
|
|
viewer={viewer}
|
|
busy={busy}
|
|
approvalThreshold={approvalThreshold}
|
|
onApprove={handleApprove}
|
|
onMarkPaid={handleMarkPaid}
|
|
onDocument={handleDocument}
|
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
|
onSaveBudget={handleSaveBudget}
|
|
onDeleteBudget={handleDeleteBudget}
|
|
onDeleteExpense={handleDeleteExpense}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
|
|
const desktopSectionContent =
|
|
desktopSection === "overview" ? (
|
|
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
|
|
<Box sx={{ width: { xs: "100%", xl: 380 }, flexShrink: 0 }}>{actionCards}</Box>
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>{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="h5" sx={{ color: alpha("#FFFFFF", 0.82), maxWidth: 760 }}>
|
|
{`Aktuelle \u00dcbersicht: ${currentPeriod?.name ?? "Zeitraum fehlt"}. Alle AGs sind sichtbar, Mitglieder bearbeiten nur ihre eigene AG.`}
|
|
</Typography>
|
|
</Box>
|
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|
|
|