Ausgaben und Spenden bearbeitbar machen
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
This commit is contained in:
193
src/app/api/donations/[id]/route.ts
Normal file
193
src/app/api/donations/[id]/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotDonation } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { canManageBudgets } from "@/lib/domain";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentViewer } from "@/lib/session";
|
||||
|
||||
type Context = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type DonationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: unknown;
|
||||
donated_at: Date;
|
||||
period_id: string;
|
||||
expense_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
function parseDateInput(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day] = match;
|
||||
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
|
||||
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function normalizeDonation(row: DonationRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
amount: row.amount,
|
||||
donatedAt: row.donated_at,
|
||||
periodId: row.period_id,
|
||||
expenseId: row.expense_id,
|
||||
creatorId: row.creator_id,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
async function getDonation(id: string) {
|
||||
const rows = await prisma.$queryRaw<DonationRow[]>`
|
||||
SELECT id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at
|
||||
FROM donations
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
const donationSchema = z
|
||||
.object({
|
||||
title: z.string().trim().min(2).max(120),
|
||||
description: z
|
||||
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null)),
|
||||
amount: z.coerce.number().positive(),
|
||||
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
|
||||
expenseId: z
|
||||
.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null))
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.donatedAt === "invalid") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Bitte ein gültiges Spendendatum angeben.",
|
||||
path: ["donatedAt"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden bearbeiten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const donation = await getDonation(id);
|
||||
|
||||
if (!donation) {
|
||||
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = donationSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success || !(parsed.data.donatedAt instanceof Date)) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.success ? "Bitte Spendendaten korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Spendendaten korrekt ausfüllen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const expense = parsed.data.expenseId
|
||||
? await prisma.expense.findUnique({
|
||||
where: { id: parsed.data.expenseId }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (parsed.data.expenseId && (!expense || expense.periodId !== donation.period_id)) {
|
||||
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum der Spende." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.$executeRaw`
|
||||
UPDATE donations
|
||||
SET title = ${parsed.data.title},
|
||||
description = ${parsed.data.description},
|
||||
amount = ${parsed.data.amount},
|
||||
donated_at = ${parsed.data.donatedAt},
|
||||
expense_id = ${expense?.id ?? null},
|
||||
updated_at = ${new Date()}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
const updatedDonation = await getDonation(id);
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "donation.update",
|
||||
entityType: "donation",
|
||||
entityId: id,
|
||||
entityLabel: parsed.data.title,
|
||||
summary: `Spende ${parsed.data.title} wurde bearbeitet.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "donation.update",
|
||||
previous: snapshotDonation(normalizeDonation(donation)),
|
||||
next: updatedDonation ? snapshotDonation(normalizeDonation(updatedDonation)) : null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!canManageBudgets(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden löschen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const donation = await getDonation(id);
|
||||
|
||||
if (!donation) {
|
||||
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.$executeRaw`DELETE FROM donations WHERE id = ${id}`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "donation.delete",
|
||||
entityType: "donation",
|
||||
entityId: id,
|
||||
entityLabel: donation.title,
|
||||
summary: `Spende ${donation.title} wurde gelöscht.`,
|
||||
metadata: {
|
||||
rollback: {
|
||||
kind: "donation.delete",
|
||||
deleted: snapshotDonation(normalizeDonation(donation))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
@@ -12,6 +13,95 @@ type Context = {
|
||||
}>;
|
||||
};
|
||||
|
||||
const updateExpenseSchema = z.object({
|
||||
title: z.string().trim().min(2).max(120),
|
||||
description: z
|
||||
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
|
||||
amount: z.coerce.number().positive(),
|
||||
agId: z.string().trim().min(1),
|
||||
budgetId: z.string().trim().min(1),
|
||||
cutoffPhase: z.enum(["PRE", "POST"])
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!hasAdministrativeAccess(viewer.role)) {
|
||||
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Ausgaben bearbeiten." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = updateExpenseSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Bitte Ausgabendaten korrekt ausfüllen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const expense = await prisma.expense.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const budget = await prisma.budget.findUnique({
|
||||
where: { id: parsed.data.budgetId }
|
||||
});
|
||||
|
||||
if (!budget || budget.workingGroupId !== parsed.data.agId || budget.periodId !== expense.periodId) {
|
||||
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
|
||||
}
|
||||
|
||||
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
|
||||
SELECT cutoff_phase FROM expenses WHERE id = ${id}
|
||||
`;
|
||||
const previousSnapshot = snapshotExpense({
|
||||
...expense,
|
||||
cutoffPhase: previousCutoffRows[0]?.cutoff_phase ?? "PRE"
|
||||
});
|
||||
|
||||
const updatedExpense = await prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
amount: parsed.data.amount,
|
||||
agId: parsed.data.agId,
|
||||
budgetId: parsed.data.budgetId
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
action: "expense.update",
|
||||
entityType: "expense",
|
||||
entityId: updatedExpense.id,
|
||||
entityLabel: updatedExpense.title,
|
||||
summary: `Ausgabe ${updatedExpense.title} wurde bearbeitet.`,
|
||||
metadata: {
|
||||
amount: Number(updatedExpense.amount),
|
||||
budgetId: updatedExpense.budgetId,
|
||||
workingGroupId: updatedExpense.agId,
|
||||
cutoffPhase: parsed.data.cutoffPhase,
|
||||
rollback: {
|
||||
kind: "expense.update",
|
||||
previous: previousSnapshot,
|
||||
next: snapshotExpense({ ...updatedExpense, cutoffPhase: parsed.data.cutoffPhase })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense: updatedExpense });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: Context) {
|
||||
const { id } = await params;
|
||||
const viewer = await getCurrentViewer();
|
||||
|
||||
@@ -160,12 +160,18 @@ export default async function DashboardPage() {
|
||||
created_at: Date;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
working_group_id: string | null;
|
||||
working_group_name: string | null;
|
||||
expense_title: string | null;
|
||||
}[]
|
||||
>`
|
||||
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
|
||||
u.id AS creator_id, u.username AS creator_name
|
||||
u.id AS creator_id, u.username AS creator_name,
|
||||
wg.id AS working_group_id, wg.name AS working_group_name, e.title AS expense_title
|
||||
FROM donations d
|
||||
JOIN users u ON u.id = d.creator_id
|
||||
LEFT JOIN expenses e ON e.id = d.expense_id
|
||||
LEFT JOIN working_groups wg ON wg.id = e.ag_id
|
||||
WHERE d.period_id = ${currentPeriod.id}
|
||||
ORDER BY d.donated_at DESC
|
||||
`;
|
||||
@@ -309,6 +315,9 @@ export default async function DashboardPage() {
|
||||
donatedAt: donation.donated_at.toISOString(),
|
||||
periodId: donation.period_id,
|
||||
expenseId: donation.expense_id,
|
||||
workingGroupId: donation.working_group_id,
|
||||
workingGroupName: donation.working_group_name,
|
||||
expenseTitle: donation.expense_title,
|
||||
createdAt: donation.created_at.toISOString(),
|
||||
creator: {
|
||||
id: donation.creator_id,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
|
||||
type BudgetColumnProps = {
|
||||
group: DashboardWorkingGroup;
|
||||
workingGroups: DashboardWorkingGroup[];
|
||||
viewer: DashboardViewer;
|
||||
busy: boolean;
|
||||
approvalThreshold: number;
|
||||
@@ -60,6 +61,17 @@ type BudgetColumnProps = {
|
||||
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
||||
onDeleteBudget: (budgetId: string) => Promise<void>;
|
||||
onDeleteExpense: (expenseId: string) => Promise<void>;
|
||||
onUpdateExpense: (
|
||||
expenseId: string,
|
||||
draft: {
|
||||
title: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
agId: string;
|
||||
budgetId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
}
|
||||
) => Promise<void>;
|
||||
};
|
||||
type BudgetDraft = {
|
||||
name: string;
|
||||
@@ -113,6 +125,18 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
||||
{expense.documentedAt ? (
|
||||
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
||||
) : null}
|
||||
{expense.donationAmount > 0 ? (
|
||||
<Chip
|
||||
label={`Spende: ${formatCurrency(expense.donationAmount)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
...wrappingChipSx,
|
||||
bgcolor: "#F6C343",
|
||||
color: "#1F1600",
|
||||
fontWeight: 700
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{expense.recurrence === "MONTHLY" ? (
|
||||
<Chip
|
||||
label={recurrenceLabel(expense.recurrence)}
|
||||
@@ -140,6 +164,7 @@ function getPaidSpend(expenses: DashboardExpense[]) {
|
||||
|
||||
export function BudgetColumn({
|
||||
group,
|
||||
workingGroups,
|
||||
viewer,
|
||||
busy,
|
||||
approvalThreshold,
|
||||
@@ -153,7 +178,8 @@ export function BudgetColumn({
|
||||
onDeleteWorkingGroup,
|
||||
onSaveBudget,
|
||||
onDeleteBudget,
|
||||
onDeleteExpense
|
||||
onDeleteExpense,
|
||||
onUpdateExpense
|
||||
}: BudgetColumnProps) {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
@@ -165,13 +191,19 @@ export function BudgetColumn({
|
||||
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
|
||||
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||
const [expandedExpenseDetails, setExpandedExpenseDetails] = useState<Record<string, boolean>>({});
|
||||
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
|
||||
const [expenseDrafts, setExpenseDrafts] = useState<
|
||||
Record<string, { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffPhase: "PRE" | "POST" }>
|
||||
>({});
|
||||
|
||||
const budgetCardWidth = 318;
|
||||
const desktopBudgetGap = 14;
|
||||
const budgetCardWidth = 286;
|
||||
const desktopBudgetGap = 12;
|
||||
const desktopBudgetListWidth =
|
||||
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 58, 410);
|
||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
|
||||
const canEditBudgets = canManageBudgets(viewer.role);
|
||||
const canEditExpenses = canManageBudgets(viewer.role);
|
||||
|
||||
useEffect(() => {
|
||||
setBudgetDrafts(
|
||||
@@ -234,6 +266,27 @@ export function BudgetColumn({
|
||||
}));
|
||||
}
|
||||
|
||||
function getExpenseDraft(expense: DashboardExpense) {
|
||||
return expenseDrafts[expense.id] ?? {
|
||||
title: expense.title,
|
||||
description: expense.description ?? "",
|
||||
amount: expense.amount.toFixed(2),
|
||||
agId: group.id,
|
||||
budgetId: expense.budgetId,
|
||||
cutoffPhase: expense.cutoffPhase
|
||||
};
|
||||
}
|
||||
|
||||
function updateExpenseDraft(expense: DashboardExpense, patch: Partial<ReturnType<typeof getExpenseDraft>>) {
|
||||
setExpenseDrafts((current) => ({
|
||||
...current,
|
||||
[expense.id]: {
|
||||
...getExpenseDraft(expense),
|
||||
...patch
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function resetDraft(budget: DashboardBudget) {
|
||||
setBudgetDrafts((current) => ({
|
||||
...current,
|
||||
@@ -738,14 +791,15 @@ export function BudgetColumn({
|
||||
: [];
|
||||
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
||||
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
||||
const isDetailsExpanded = expandedExpenseDetails[expense.id] ?? false;
|
||||
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={expense.id}
|
||||
sx={{
|
||||
p: 2.25,
|
||||
borderRadius: "18px",
|
||||
p: 1.55,
|
||||
borderRadius: "14px",
|
||||
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
||||
backgroundColor:
|
||||
expense.approvalStatus === "APPROVED"
|
||||
@@ -754,8 +808,8 @@ export function BudgetColumn({
|
||||
touchAction: "pan-x pan-y"
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.4}>
|
||||
<Stack spacing={1}>
|
||||
<Stack spacing={1}>
|
||||
<Stack spacing={0.75}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
@@ -766,19 +820,171 @@ export function BudgetColumn({
|
||||
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||
{isRecurringSeries
|
||||
? expense.occurrenceCount > 0
|
||||
? `${formatCurrency(expense.netPeriodAmount)} netto im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
||||
? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
||||
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}`
|
||||
: `${formatCurrency(expense.netPeriodAmount)} netto von ${expense.creator.name}`}
|
||||
: `${formatCurrency(expense.amount)} von ${expense.creator.name}`}
|
||||
</Typography>
|
||||
{expense.donationAmount > 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`}
|
||||
{`Rest: ${formatCurrency(expense.netPeriodAmount)}`}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<StatusChips expense={expense} />
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setExpandedExpenseDetails((current) => ({
|
||||
...current,
|
||||
[expense.id]: !isDetailsExpanded
|
||||
}))
|
||||
}
|
||||
endIcon={isDetailsExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
|
||||
sx={{ alignSelf: "flex-start", px: 0 }}
|
||||
>
|
||||
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||
</Button>
|
||||
|
||||
{canEditExpenses ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditingExpenseId(expense.id);
|
||||
setExpenseDrafts((current) => ({
|
||||
...current,
|
||||
[expense.id]: getExpenseDraft(expense)
|
||||
}));
|
||||
}}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{editingExpenseId === expense.id ? (
|
||||
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
|
||||
{(() => {
|
||||
const draft = getExpenseDraft(expense);
|
||||
const editGroup =
|
||||
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||
const editBudgets = editGroup.budgets;
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
label="Titel"
|
||||
size="small"
|
||||
value={draft.title}
|
||||
onChange={(event) => updateExpenseDraft(expense, { title: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Beschreibung"
|
||||
size="small"
|
||||
value={draft.description}
|
||||
onChange={(event) => updateExpenseDraft(expense, { description: event.target.value })}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
<TextField
|
||||
label="Betrag in EUR"
|
||||
type="number"
|
||||
size="small"
|
||||
inputProps={{ min: 0.01, step: 0.01 }}
|
||||
value={draft.amount}
|
||||
onChange={(event) => updateExpenseDraft(expense, { amount: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="AG"
|
||||
size="small"
|
||||
value={draft.agId}
|
||||
onChange={(event) => {
|
||||
const nextGroup = workingGroups.find((entry) => entry.id === event.target.value);
|
||||
updateExpenseDraft(expense, {
|
||||
agId: event.target.value,
|
||||
budgetId: nextGroup?.budgets[0]?.id ?? ""
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
{workingGroups.map((entry) => (
|
||||
<MenuItem key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Budget"
|
||||
size="small"
|
||||
value={draft.budgetId}
|
||||
onChange={(event) => updateExpenseDraft(expense, { budgetId: event.target.value })}
|
||||
fullWidth
|
||||
disabled={editBudgets.length === 0}
|
||||
>
|
||||
{editBudgets.map((entry) => (
|
||||
<MenuItem key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
size="small"
|
||||
value={draft.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
updateExpenseDraft(expense, {
|
||||
cutoffPhase: event.target.value as "PRE" | "POST"
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">Pre Open Air</MenuItem>
|
||||
<MenuItem value="POST">Post Open Air</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={busy || !draft.budgetId}
|
||||
onClick={async () => {
|
||||
await onUpdateExpense(expense.id, draft);
|
||||
setEditingExpenseId(null);
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={busy}
|
||||
onClick={() => setEditingExpenseId(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Collapse in={isDetailsExpanded} unmountOnExit>
|
||||
<Stack spacing={1}>
|
||||
{expense.description ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||
{expense.description}
|
||||
@@ -1075,6 +1281,8 @@ export function BudgetColumn({
|
||||
new Date(expense.createdAt)
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -100,9 +100,21 @@ type DonationFormState = {
|
||||
amount: string;
|
||||
donatedAt: string;
|
||||
target: "GENERAL" | "EXPENSE";
|
||||
workingGroupId: string;
|
||||
expenseId: string;
|
||||
};
|
||||
|
||||
type DonationDraft = DonationFormState;
|
||||
|
||||
type ExpenseEditDraft = {
|
||||
title: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
agId: string;
|
||||
budgetId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
};
|
||||
|
||||
type WorkingGroupFormState = {
|
||||
name: string;
|
||||
};
|
||||
@@ -337,7 +349,7 @@ export function DashboardShell({
|
||||
const desktopSections = [
|
||||
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
|
||||
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
|
||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "AGs & Budgets" }] : []),
|
||||
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
||||
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
||||
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
|
||||
@@ -387,6 +399,7 @@ export function DashboardShell({
|
||||
amount: "",
|
||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||
target: "GENERAL",
|
||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||
expenseId: ""
|
||||
});
|
||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||
@@ -428,6 +441,8 @@ export function DashboardShell({
|
||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
|
||||
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
|
||||
const [donationDrafts, setDonationDrafts] = useState<Record<string, DonationDraft>>({});
|
||||
const [editingDonationId, setEditingDonationId] = useState<string | null>(null);
|
||||
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
|
||||
requiredApprovalTypes: settings.requiredApprovalTypes,
|
||||
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
||||
@@ -745,6 +760,10 @@ export function DashboardShell({
|
||||
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
|
||||
selectedBudgetReleaseOptions[0] ??
|
||||
null;
|
||||
const selectedDonationGroup =
|
||||
visibleGroups.find((group) => group.id === donationForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||
const selectedDonationGroupExpenses =
|
||||
selectedDonationGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
|
||||
const selectedBudgetReleasePaidAmount =
|
||||
selectedBudgetReleaseBudget?.expenses.reduce(
|
||||
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
|
||||
@@ -791,6 +810,28 @@ export function DashboardShell({
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function getDonationDraft(donation: DashboardDonation): DonationDraft {
|
||||
return donationDrafts[donation.id] ?? {
|
||||
title: donation.title,
|
||||
description: donation.description ?? "",
|
||||
amount: donation.amount.toFixed(2),
|
||||
donatedAt: toDateInputValue(donation.donatedAt),
|
||||
target: donation.expenseId ? "EXPENSE" : "GENERAL",
|
||||
workingGroupId: donation.workingGroupId ?? visibleGroups[0]?.id ?? "",
|
||||
expenseId: donation.expenseId ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function updateDonationDraft(donation: DashboardDonation, patch: Partial<DonationDraft>) {
|
||||
setDonationDrafts((current) => ({
|
||||
...current,
|
||||
[donation.id]: {
|
||||
...getDonationDraft(donation),
|
||||
...patch
|
||||
}
|
||||
}));
|
||||
}
|
||||
const totals = useMemo(() => {
|
||||
return visibleGroups.reduce(
|
||||
(summary, group) => {
|
||||
@@ -1153,6 +1194,7 @@ export function DashboardShell({
|
||||
amount: "",
|
||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||
target: "GENERAL",
|
||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||
expenseId: ""
|
||||
});
|
||||
}, "Spende wurde erfasst.");
|
||||
@@ -1178,6 +1220,51 @@ export function DashboardShell({
|
||||
}, "Ausgabe wurde gel\u00f6scht.");
|
||||
}
|
||||
|
||||
async function handleUpdateExpense(expenseId: string, draft: ExpenseEditDraft) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/expenses/${expenseId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(draft)
|
||||
})
|
||||
);
|
||||
}, "Ausgabe wurde bearbeitet.");
|
||||
}
|
||||
|
||||
async function handleUpdateDonation(donationId: string, draft: DonationDraft) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/donations/${donationId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
amount: draft.amount,
|
||||
donatedAt: draft.donatedAt,
|
||||
expenseId: draft.target === "EXPENSE" ? draft.expenseId : ""
|
||||
})
|
||||
})
|
||||
);
|
||||
setEditingDonationId(null);
|
||||
}, "Spende wurde bearbeitet.");
|
||||
}
|
||||
|
||||
async function handleDeleteDonation(donationId: string, title: string) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/donations/${donationId}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
);
|
||||
}, `Spende ${title} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1796,44 +1883,6 @@ export function DashboardShell({
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
|
||||
<Stack spacing={1.4}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Stichtage
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Dieser Stichtag trennt Ausgaben in Pre/Post und wird in der Finanzübersicht ausgewertet.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
|
||||
<TextField
|
||||
label="Stichtag-Name"
|
||||
value={periodEditForm.cutoffName}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
value={periodEditForm.cutoffDate}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outlined"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
||||
>
|
||||
Stichtag speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
||||
<Stack spacing={1.4}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
@@ -1893,6 +1942,44 @@ export function DashboardShell({
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null;
|
||||
|
||||
const cutoffManagementPanel = canManagePeriods ? (
|
||||
<Box component="form" onSubmit={handleSavePeriod}>
|
||||
<Stack spacing={1.4}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Stichtage
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten.
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Stichtag-Name"
|
||||
value={periodEditForm.cutoffName}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
value={periodEditForm.cutoffDate}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outlined"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
||||
>
|
||||
Stichtag speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null;
|
||||
const actionCards = (
|
||||
<Stack
|
||||
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
|
||||
@@ -2230,21 +2317,44 @@ export function DashboardShell({
|
||||
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
||||
</TextField>
|
||||
{donationForm.target === "EXPENSE" ? (
|
||||
<TextField
|
||||
select
|
||||
label="Ausgabe"
|
||||
value={donationForm.expenseId}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={allExpenses.length === 0}
|
||||
>
|
||||
{allExpenses.map((expense) => (
|
||||
<MenuItem key={expense.id} value={expense.id}>
|
||||
{expense.title} · {currencyFormatter.format(expense.netPeriodAmount)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<>
|
||||
<TextField
|
||||
select
|
||||
label="AG"
|
||||
value={donationForm.workingGroupId}
|
||||
onChange={(event) =>
|
||||
setDonationForm((current) => ({
|
||||
...current,
|
||||
workingGroupId: event.target.value,
|
||||
expenseId: ""
|
||||
}))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
disabled={visibleGroups.length === 0}
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Ausgabe"
|
||||
value={donationForm.expenseId}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={selectedDonationGroupExpenses.length === 0}
|
||||
>
|
||||
{selectedDonationGroupExpenses.map((expense) => (
|
||||
<MenuItem key={expense.id} value={expense.id}>
|
||||
{expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="submit" variant="outlined" disabled={busy}>
|
||||
Spende speichern
|
||||
@@ -2371,9 +2481,14 @@ export function DashboardShell({
|
||||
) : null}
|
||||
|
||||
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||
</Card>
|
||||
<Stack spacing={3}>
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||
</Card>
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
|
||||
@@ -2921,6 +3036,172 @@ export function DashboardShell({
|
||||
|
||||
const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null;
|
||||
const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups;
|
||||
const generalDonations = donations.filter((donation) => !donation.expenseId);
|
||||
|
||||
function renderDonationEditor(donation: DashboardDonation) {
|
||||
const draft = getDonationDraft(donation);
|
||||
const draftGroup = visibleGroups.find((group) => group.id === draft.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||
const draftExpenses = draftGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
|
||||
|
||||
if (editingDonationId !== donation.id) {
|
||||
return (
|
||||
<Stack spacing={0.8}>
|
||||
<Stack direction="row" justifyContent="space-between" gap={1}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, overflowWrap: "break-word" }}>
|
||||
{donation.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{currencyFormatter.format(donation.amount)}
|
||||
{donation.expenseTitle ? ` · ${donation.expenseTitle}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<IconButton size="small" disabled={busy} onClick={() => setEditingDonationId(donation.id)}>
|
||||
<EditRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDeleteDonation(donation.id, donation.title);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
label="Titel"
|
||||
size="small"
|
||||
value={draft.title}
|
||||
onChange={(event) => updateDonationDraft(donation, { title: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Betrag in EUR"
|
||||
type="number"
|
||||
size="small"
|
||||
inputProps={{ min: 0.01, step: 0.01 }}
|
||||
value={draft.amount}
|
||||
onChange={(event) => updateDonationDraft(donation, { amount: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Spendendatum"
|
||||
type="date"
|
||||
size="small"
|
||||
value={draft.donatedAt}
|
||||
onChange={(event) => updateDonationDraft(donation, { donatedAt: event.target.value })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Zuordnung"
|
||||
size="small"
|
||||
value={draft.target}
|
||||
onChange={(event) =>
|
||||
updateDonationDraft(donation, {
|
||||
target: event.target.value as DonationDraft["target"],
|
||||
expenseId: ""
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="GENERAL">Allgemein</MenuItem>
|
||||
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
||||
</TextField>
|
||||
{draft.target === "EXPENSE" ? (
|
||||
<>
|
||||
<TextField
|
||||
select
|
||||
label="AG"
|
||||
size="small"
|
||||
value={draft.workingGroupId}
|
||||
onChange={(event) =>
|
||||
updateDonationDraft(donation, {
|
||||
workingGroupId: event.target.value,
|
||||
expenseId: ""
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Ausgabe"
|
||||
size="small"
|
||||
value={draft.expenseId}
|
||||
onChange={(event) => updateDonationDraft(donation, { expenseId: event.target.value })}
|
||||
fullWidth
|
||||
disabled={draftExpenses.length === 0}
|
||||
>
|
||||
{draftExpenses.map((expense) => (
|
||||
<MenuItem key={expense.id} value={expense.id}>
|
||||
{expense.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</>
|
||||
) : null}
|
||||
<Stack direction="row" gap={1}>
|
||||
<Button size="small" variant="contained" disabled={busy} onClick={() => handleUpdateDonation(donation.id, draft)}>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button size="small" variant="text" disabled={busy} onClick={() => setEditingDonationId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const generalDonationsColumn = (
|
||||
<Card sx={{ ...islandCardSx, width: { xs: "100%", lg: 286 }, flex: "0 0 auto" }}>
|
||||
<CardContent sx={{ p: 2.2 }}>
|
||||
<Stack spacing={1.4}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
|
||||
Spenden
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Allgemein: {currencyFormatter.format(generalDonationTotal)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{generalDonations.length === 0 ? (
|
||||
<Box sx={{ p: 1.5, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.12) }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Noch keine allgemeinen Spenden.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
generalDonations.map((donation) => (
|
||||
<Box key={donation.id} sx={{ p: 1.4, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.16) }}>
|
||||
{renderDonationEditor(donation)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const overviewContent = (
|
||||
<Stack spacing={2.5}>
|
||||
@@ -2962,6 +3243,7 @@ export function DashboardShell({
|
||||
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
workingGroups={visibleGroups}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
@@ -2976,9 +3258,11 @@ export function DashboardShell({
|
||||
onSaveBudget={handleSaveBudget}
|
||||
onDeleteBudget={handleDeleteBudget}
|
||||
onDeleteExpense={handleDeleteExpense}
|
||||
onUpdateExpense={handleUpdateExpense}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
{generalDonationsColumn}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -3009,6 +3293,7 @@ export function DashboardShell({
|
||||
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
workingGroups={visibleGroups}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
@@ -3023,9 +3308,11 @@ export function DashboardShell({
|
||||
onSaveBudget={handleSaveBudget}
|
||||
onDeleteBudget={handleDeleteBudget}
|
||||
onDeleteExpense={handleDeleteExpense}
|
||||
onUpdateExpense={handleUpdateExpense}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
{generalDonationsColumn}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -3069,27 +3356,33 @@ export function DashboardShell({
|
||||
if (financeViewMode === "cutoff") {
|
||||
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
||||
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
||||
const cutoffDate = currentPeriod?.cutoffDate ? new Date(currentPeriod.cutoffDate) : null;
|
||||
const preGeneralDonations = donations
|
||||
.filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate))
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
const postGeneralDonations = donations
|
||||
.filter((donation) => !donation.expenseId && cutoffDate && new Date(donation.donatedAt) > cutoffDate)
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
const preAssignedDonations = donations
|
||||
.filter((donation) => donation.expenseId && pre.some((expense) => expense.id === donation.expenseId))
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
const postAssignedDonations = donations
|
||||
.filter((donation) => donation.expenseId && post.some((expense) => expense.id === donation.expenseId))
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
return [
|
||||
{
|
||||
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
donations: assignedDonationTotal
|
||||
donations: preAssignedDonations + preGeneralDonations
|
||||
},
|
||||
{
|
||||
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
donations: 0
|
||||
},
|
||||
{
|
||||
label: "Allgemeine Spenden",
|
||||
planned: 0,
|
||||
approved: 0,
|
||||
paid: 0,
|
||||
donations: generalDonationTotal
|
||||
donations: postAssignedDonations + postGeneralDonations
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -3135,9 +3428,9 @@ export function DashboardShell({
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
|
||||
<Chip label={`Bezahlt netto: ${currencyFormatter.format(paidTotal)}`} color="info" />
|
||||
<Chip label={`Bezahlt: ${currencyFormatter.format(paidTotal)}`} color="info" />
|
||||
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
|
||||
<Chip label={`Netto-Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
||||
<Chip label={`Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
@@ -3213,6 +3506,11 @@ export function DashboardShell({
|
||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{canManagePeriods ? (
|
||||
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 420 }, flexShrink: 0 }}>
|
||||
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ width: "100%" }}>{actionCards}</Box>
|
||||
|
||||
@@ -93,6 +93,9 @@ export type DashboardDonation = {
|
||||
donatedAt: string;
|
||||
periodId: string;
|
||||
expenseId: string | null;
|
||||
workingGroupId: string | null;
|
||||
workingGroupName: string | null;
|
||||
expenseTitle: string | null;
|
||||
createdAt: string;
|
||||
creator: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user