diff --git a/src/app/api/donations/[id]/route.ts b/src/app/api/donations/[id]/route.ts new file mode 100644 index 0000000..7c764e7 --- /dev/null +++ b/src/app/api/donations/[id]/route.ts @@ -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` + 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 }); +} diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index e51f7b1..5b9d36c 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -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(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 195521e..2b0c214 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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, diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 5de9463..518d712 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -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; onDeleteBudget: (budgetId: string) => Promise; onDeleteExpense: (expenseId: string) => Promise; + onUpdateExpense: ( + expenseId: string, + draft: { + title: string; + description: string; + amount: string; + agId: string; + budgetId: string; + cutoffPhase: "PRE" | "POST"; + } + ) => Promise; }; type BudgetDraft = { name: string; @@ -113,6 +125,18 @@ function StatusChips({ expense }: { expense: DashboardExpense }) { {expense.documentedAt ? ( } sx={wrappingChipSx} /> ) : null} + {expense.donationAmount > 0 ? ( + + ) : null} {expense.recurrence === "MONTHLY" ? ( >({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); + const [expandedExpenseDetails, setExpandedExpenseDetails] = useState>({}); + const [editingExpenseId, setEditingExpenseId] = useState(null); + const [expenseDrafts, setExpenseDrafts] = useState< + Record + >({}); - 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>) { + 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 ( - - + + {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}`} {expense.donationAmount > 0 ? ( - {`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`} + {`Rest: ${formatCurrency(expense.netPeriodAmount)}`} ) : null} + + + {canEditExpenses ? ( + + ) : null} + + {editingExpenseId === expense.id ? ( + + {(() => { + const draft = getExpenseDraft(expense); + const editGroup = + workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group; + const editBudgets = editGroup.budgets; + + return ( + + updateExpenseDraft(expense, { title: event.target.value })} + fullWidth + /> + updateExpenseDraft(expense, { description: event.target.value })} + fullWidth + multiline + minRows={2} + /> + updateExpenseDraft(expense, { amount: event.target.value })} + fullWidth + /> + { + 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) => ( + + {entry.name} + + ))} + + updateExpenseDraft(expense, { budgetId: event.target.value })} + fullWidth + disabled={editBudgets.length === 0} + > + {editBudgets.map((entry) => ( + + {entry.name} + + ))} + + + updateExpenseDraft(expense, { + cutoffPhase: event.target.value as "PRE" | "POST" + }) + } + fullWidth + > + Pre Open Air + Post Open Air + + + + + + + ); + })()} + + ) : null} + + + {expense.description ? ( {expense.description} @@ -1075,6 +1281,8 @@ export function BudgetColumn({ new Date(expense.createdAt) )} + + ); diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 4aaf008..6487086 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -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({ @@ -428,6 +441,8 @@ export function DashboardShell({ const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false); const [driveDiagnosticResult, setDriveDiagnosticResult] = useState(null); + const [donationDrafts, setDonationDrafts] = useState>({}); + const [editingDonationId, setEditingDonationId] = useState(null); const [orgaSettingsDraft, setOrgaSettingsDraft] = useState({ 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) { + 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) { event.preventDefault(); @@ -1796,44 +1883,6 @@ export function DashboardShell({ - - - - Stichtage - - - Dieser Stichtag trennt Ausgaben in Pre/Post und wird in der Finanzübersicht ausgewertet. - - - setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))} - required - fullWidth - disabled={!selectedPeriodForManagement} - /> - setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))} - InputLabelProps={{ shrink: true }} - fullWidth - disabled={!selectedPeriodForManagement} - /> - - - - - @@ -1893,6 +1942,44 @@ export function DashboardShell({ ) : null; + + const cutoffManagementPanel = canManagePeriods ? ( + + + + Stichtage + + + Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten. + + setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))} + required + fullWidth + disabled={!selectedPeriodForManagement} + /> + setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))} + InputLabelProps={{ shrink: true }} + fullWidth + disabled={!selectedPeriodForManagement} + /> + + + + ) : null; const actionCards = ( Ausgabe zugeordnet {donationForm.target === "EXPENSE" ? ( - setDonationForm((current) => ({ ...current, expenseId: event.target.value }))} - required - fullWidth - disabled={allExpenses.length === 0} - > - {allExpenses.map((expense) => ( - - {expense.title} · {currencyFormatter.format(expense.netPeriodAmount)} - - ))} - + <> + + setDonationForm((current) => ({ + ...current, + workingGroupId: event.target.value, + expenseId: "" + })) + } + required + fullWidth + disabled={visibleGroups.length === 0} + > + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + setDonationForm((current) => ({ ...current, expenseId: event.target.value }))} + required + fullWidth + disabled={selectedDonationGroupExpenses.length === 0} + > + {selectedDonationGroupExpenses.map((expense) => ( + + {expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)} + + ))} + + ) : null} + + + + ); + } + + const generalDonationsColumn = ( + + + + + + Spenden + + + Allgemein: {currencyFormatter.format(generalDonationTotal)} + + + {generalDonations.length === 0 ? ( + + + Noch keine allgemeinen Spenden. + + + ) : ( + generalDonations.map((donation) => ( + + {renderDonationEditor(donation)} + + )) + )} + + + + ); const overviewContent = ( @@ -2962,6 +3243,7 @@ export function DashboardShell({ ))} + {generalDonationsColumn} ) : ( @@ -3009,6 +3293,7 @@ export function DashboardShell({ ))} + {generalDonationsColumn} )} @@ -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({ - + - + @@ -3213,6 +3506,11 @@ export function DashboardShell({ {periodManagementPanel} ) : null} + {canManagePeriods ? ( + + {cutoffManagementPanel} + + ) : null} ) : ( {actionCards} diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index 3e94c1e..6d0c4a7 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -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;