Ausgaben und Spenden bearbeitbar machen
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s

This commit is contained in:
jan
2026-05-12 00:47:07 +02:00
parent c738b35d06
commit a527a840ee
6 changed files with 881 additions and 80 deletions

View 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 });
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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;