From 5591d10d964132fbd95d98791c768ec21ab1661f Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 12 May 2026 01:37:28 +0200 Subject: [PATCH] Mehrere Stichtage pro Zeitraum verwalten --- .../migration.sql | 37 ++ prisma/schema.prisma | 17 + src/app/api/expenses/[id]/route.ts | 25 +- src/app/api/expenses/route.ts | 25 +- src/app/api/period-cutoffs/[id]/route.ts | 183 +++++++++ src/app/api/periods/[id]/cutoffs/route.ts | 107 +++++ src/app/api/periods/route.ts | 8 + src/app/page.tsx | 35 +- src/components/dashboard/budget-column.tsx | 67 +++- src/components/dashboard/dashboard-shell.tsx | 365 +++++++++++++++--- src/lib/dashboard-types.ts | 9 + 11 files changed, 790 insertions(+), 88 deletions(-) create mode 100644 prisma/migrations/202605121200_multiple_period_cutoffs/migration.sql create mode 100644 src/app/api/period-cutoffs/[id]/route.ts create mode 100644 src/app/api/periods/[id]/cutoffs/route.ts diff --git a/prisma/migrations/202605121200_multiple_period_cutoffs/migration.sql b/prisma/migrations/202605121200_multiple_period_cutoffs/migration.sql new file mode 100644 index 0000000..262e03c --- /dev/null +++ b/prisma/migrations/202605121200_multiple_period_cutoffs/migration.sql @@ -0,0 +1,37 @@ +CREATE TABLE "period_cutoffs" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "date" TIMESTAMP(3), + "period_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "period_cutoffs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "period_cutoffs_period_id_idx" ON "period_cutoffs"("period_id"); + +ALTER TABLE "period_cutoffs" + ADD CONSTRAINT "period_cutoffs_period_id_fkey" + FOREIGN KEY ("period_id") REFERENCES "accounting_periods"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "expenses" ADD COLUMN "cutoff_id" TEXT; + +INSERT INTO "period_cutoffs" ("id", "name", "date", "period_id", "created_at", "updated_at") +SELECT + 'cutoff_' || md5("id"), + COALESCE(NULLIF("cutoff_name", ''), 'Open Air'), + "cutoff_date", + "id", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "accounting_periods"; + +UPDATE "expenses" +SET "cutoff_id" = 'cutoff_' || md5("period_id"); + +ALTER TABLE "expenses" + ADD CONSTRAINT "expenses_cutoff_id_fkey" + FOREIGN KEY ("cutoff_id") REFERENCES "period_cutoffs"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7900133..b08cd7f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,7 @@ model AccountingPeriod { isCurrent Boolean @default(false) @map("is_current") cutoffName String @default("Open Air") @map("cutoff_name") cutoffDate DateTime? @map("cutoff_date") + cutoffs PeriodCutoff[] budgets Budget[] expenses Expense[] donations Donation[] @@ -95,6 +96,20 @@ model AccountingPeriod { @@map("accounting_periods") } +model PeriodCutoff { + id String @id @default(cuid()) + name String + date DateTime? @map("date") + periodId String @map("period_id") + period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Cascade) + expenses Expense[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([periodId]) + @@map("period_cutoffs") +} + model AppSettings { id String @id @default("global") approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold") @@ -148,6 +163,7 @@ model Expense { approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") recurrence ExpenseRecurrence @default(NONE) recurrenceStartAt DateTime? @map("recurrence_start_at") + cutoffId String? @map("cutoff_id") cutoffPhase CutoffPhase @default(PRE) @map("cutoff_phase") paidAt DateTime? @map("paid_at") documentedAt DateTime? @map("documented_at") @@ -157,6 +173,7 @@ model Expense { workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade) budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) + cutoff PeriodCutoff? @relation(fields: [cutoffId], references: [id], onDelete: SetNull) approvals Approval[] documents ExpenseDocument[] donations Donation[] diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index 5b9d36c..8545800 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -21,6 +21,7 @@ const updateExpenseSchema = z.object({ amount: z.coerce.number().positive(), agId: z.string().trim().min(1), budgetId: z.string().trim().min(1), + cutoffId: z.string().trim().min(1).nullable().optional(), cutoffPhase: z.enum(["PRE", "POST"]) }); @@ -59,6 +60,24 @@ export async function PATCH(request: Request, { params }: Context) { return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 }); } + const cutoffRows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId} + `; + const fallbackCutoffRows = parsed.data.cutoffId + ? [] + : await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE period_id = ${expense.periodId} + ORDER BY date ASC NULLS LAST, created_at ASC + LIMIT 1 + `; + const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null; + + if (parsed.data.cutoffId && !cutoffId) { + return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 }); + } + const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>` SELECT cutoff_phase FROM expenses WHERE id = ${id} `; @@ -77,7 +96,11 @@ export async function PATCH(request: Request, { params }: Context) { budgetId: parsed.data.budgetId } }); - await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`; + await prisma.$executeRaw` + UPDATE expenses + SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" + WHERE id = ${id} + `; await createAuditLog(prisma, { actorId: viewer.id, diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index 214ac17..2d9c9a2 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -31,6 +31,7 @@ const expenseSchema = z amount: z.coerce.number().positive(), agId: z.string().trim().min(1), budgetId: z.string().trim().min(1), + cutoffId: z.string().trim().min(1).optional(), cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"), recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"), recurrenceStartAt: z @@ -93,6 +94,24 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG." }, { status: 404 }); } + const cutoffRows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${budget.periodId} + `; + const fallbackCutoffRows = parsed.data.cutoffId + ? [] + : await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM period_cutoffs + WHERE period_id = ${budget.periodId} + ORDER BY date ASC NULLS LAST, created_at ASC + LIMIT 1 + `; + const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null; + + if (parsed.data.cutoffId && !cutoffId) { + return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 }); + } + const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold); const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes); const recurrenceStartAt = @@ -115,7 +134,11 @@ export async function POST(request: Request) { approvalStatus: needsManualApproval ? "PENDING" : "APPROVED" } }); - await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${expense.id}`; + await prisma.$executeRaw` + UPDATE expenses + SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" + WHERE id = ${expense.id} + `; if (needsManualApproval) { await notifyApprovalRequest( diff --git a/src/app/api/period-cutoffs/[id]/route.ts b/src/app/api/period-cutoffs/[id]/route.ts new file mode 100644 index 0000000..1069f50 --- /dev/null +++ b/src/app/api/period-cutoffs/[id]/route.ts @@ -0,0 +1,183 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +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 CutoffRow = { + id: string; + name: string; + date: Date | null; + period_id: string; +}; + +function parseDateInput(value: string | null | undefined) { + if (!value) { + return null; + } + + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + + if (!match) { + return "invalid"; + } + + 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()) ? "invalid" : parsed; +} + +async function getCutoff(id: string) { + const rows = await prisma.$queryRaw` + SELECT id, name, date, period_id + FROM period_cutoffs + WHERE id = ${id} + `; + + return rows[0] ?? null; +} + +const cutoffSchema = z + .object({ + name: z.string().trim().min(2).max(80), + date: z + .union([z.string().trim(), z.literal(""), z.null(), z.undefined()]) + .transform((value) => parseDateInput(typeof value === "string" ? value : null)) + }) + .superRefine((value, ctx) => { + if (value.date === "invalid") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bitte ein gültiges Stichtag-Datum angeben.", + path: ["date"] + }); + } + }); + +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 Stichtage bearbeiten." }, { status: 403 }); + } + + const cutoff = await getCutoff(id); + + if (!cutoff) { + return NextResponse.json({ error: "Stichtag nicht gefunden." }, { status: 404 }); + } + + const body = await request.json().catch(() => null); + const parsed = cutoffSchema.safeParse(body); + + if (!parsed.success || parsed.data.date === "invalid") { + return NextResponse.json( + { error: parsed.success ? "Bitte Stichtag korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Stichtag korrekt ausfüllen." }, + { status: 400 } + ); + } + + const nextDate = parsed.data.date instanceof Date ? parsed.data.date : null; + + await prisma.$executeRaw` + UPDATE period_cutoffs + SET name = ${parsed.data.name}, date = ${nextDate}, updated_at = ${new Date()} + WHERE id = ${id} + `; + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "periodCutoff.update", + entityType: "periodCutoff", + entityId: id, + entityLabel: parsed.data.name, + summary: `Stichtag ${parsed.data.name} wurde bearbeitet.`, + metadata: { + periodId: cutoff.period_id, + previous: { + name: cutoff.name, + date: cutoff.date?.toISOString() ?? null + }, + next: { + name: parsed.data.name, + date: nextDate?.toISOString() ?? 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 Stichtage löschen." }, { status: 403 }); + } + + const cutoff = await getCutoff(id); + + if (!cutoff) { + return NextResponse.json({ error: "Stichtag nicht gefunden." }, { status: 404 }); + } + + const countRows = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*)::bigint AS count + FROM period_cutoffs + WHERE period_id = ${cutoff.period_id} + `; + + if (Number(countRows[0]?.count ?? 0) <= 1) { + return NextResponse.json({ error: "Der letzte Stichtag eines Zeitraums kann nicht gelöscht werden." }, { status: 400 }); + } + + const replacementRows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id + FROM period_cutoffs + WHERE period_id = ${cutoff.period_id} AND id <> ${id} + ORDER BY date ASC NULLS LAST, created_at ASC + LIMIT 1 + `; + const replacementCutoffId = replacementRows[0]?.id ?? null; + + await prisma.$executeRaw`UPDATE expenses SET cutoff_id = ${replacementCutoffId} WHERE cutoff_id = ${id}`; + await prisma.$executeRaw`DELETE FROM period_cutoffs WHERE id = ${id}`; + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "periodCutoff.delete", + entityType: "periodCutoff", + entityId: id, + entityLabel: cutoff.name, + summary: `Stichtag ${cutoff.name} wurde gelöscht.`, + metadata: { + periodId: cutoff.period_id, + deleted: { + name: cutoff.name, + date: cutoff.date?.toISOString() ?? null + }, + replacementCutoffId + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/periods/[id]/cutoffs/route.ts b/src/app/api/periods/[id]/cutoffs/route.ts new file mode 100644 index 0000000..117c802 --- /dev/null +++ b/src/app/api/periods/[id]/cutoffs/route.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +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; + }>; +}; + +function parseDateInput(value: string | null | undefined) { + if (!value) { + return null; + } + + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + + if (!match) { + return "invalid"; + } + + 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()) ? "invalid" : parsed; +} + +const cutoffSchema = z + .object({ + name: z.string().trim().min(2).max(80), + date: z + .union([z.string().trim(), z.literal(""), z.null(), z.undefined()]) + .transform((value) => parseDateInput(typeof value === "string" ? value : null)) + }) + .superRefine((value, ctx) => { + if (value.date === "invalid") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bitte ein gültiges Stichtag-Datum angeben.", + path: ["date"] + }); + } + }); + +export async function POST(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 Stichtage anlegen." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = cutoffSchema.safeParse(body); + + if (!parsed.success || parsed.data.date === "invalid") { + return NextResponse.json( + { error: parsed.success ? "Bitte Stichtag korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Stichtag korrekt ausfüllen." }, + { status: 400 } + ); + } + + const period = await prisma.accountingPeriod.findUnique({ + where: { id } + }); + + if (!period) { + return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 }); + } + + const cutoff = { + id: randomUUID(), + name: parsed.data.name, + date: parsed.data.date instanceof Date ? parsed.data.date : null, + periodId: period.id, + createdAt: new Date() + }; + + await prisma.$executeRaw` + INSERT INTO period_cutoffs (id, name, date, period_id, created_at, updated_at) + VALUES (${cutoff.id}, ${cutoff.name}, ${cutoff.date}, ${cutoff.periodId}, ${cutoff.createdAt}, ${cutoff.createdAt}) + `; + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "periodCutoff.create", + entityType: "periodCutoff", + entityId: cutoff.id, + entityLabel: cutoff.name, + summary: `Stichtag ${cutoff.name} wurde angelegt.`, + metadata: { + periodId: cutoff.periodId, + date: cutoff.date?.toISOString() ?? null + } + }); + + return NextResponse.json({ cutoff }); +} diff --git a/src/app/api/periods/route.ts b/src/app/api/periods/route.ts index f1cd20f..8a5b24e 100644 --- a/src/app/api/periods/route.ts +++ b/src/app/api/periods/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -50,6 +51,13 @@ export async function POST(request: Request) { isCurrent: false } }); + const defaultCutoffId = randomUUID(); + const now = new Date(); + + await tx.$executeRaw` + INSERT INTO period_cutoffs (id, name, date, period_id, created_at, updated_at) + VALUES (${defaultCutoffId}, ${"Open Air"}, ${null}, ${createdPeriod.id}, ${now}, ${now}) + `; if (copyBudgetsFromPeriodId) { const sourceBudgets = await tx.budget.findMany({ diff --git a/src/app/page.tsx b/src/app/page.tsx index ad9375c..afdc7ab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -143,11 +143,15 @@ export default async function DashboardPage() { : []; const periodCutoffs = await prisma.$queryRaw< - { id: string; cutoff_name: string; cutoff_date: Date | null }[] - >`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`; + { id: string; name: string; date: Date | null; period_id: string }[] + >` + SELECT id, name, date, period_id + FROM period_cutoffs + ORDER BY date ASC NULLS LAST, created_at ASC + `; const expenseCutoffs = await prisma.$queryRaw< - { id: string; cutoff_phase: "PRE" | "POST" }[] - >`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`; + { id: string; cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[] + >`SELECT id, cutoff_id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`; const donationRows = await prisma.$queryRaw< { id: string; @@ -175,8 +179,14 @@ export default async function DashboardPage() { WHERE d.period_id = ${currentPeriod.id} ORDER BY d.donated_at DESC `; - const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period])); - const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase])); + const periodCutoffsByPeriodId = new Map(); + for (const cutoff of periodCutoffs) { + periodCutoffsByPeriodId.set(cutoff.period_id, [...(periodCutoffsByPeriodId.get(cutoff.period_id) ?? []), cutoff]); + } + const primaryCutoffByPeriodId = new Map( + [...periodCutoffsByPeriodId.entries()].map(([periodId, cutoffs]) => [periodId, cutoffs[0]]) + ); + const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense])); const donationsByExpenseId = new Map(); const donationRowsByExpenseId = new Map(); for (const donation of donationRows) { @@ -248,7 +258,8 @@ export default async function DashboardPage() { approvalStatus: expense.approvalStatus, recurrence: expense.recurrence, recurrenceStartAt, - cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE", + cutoffId: expenseCutoffById.get(expense.id)?.cutoff_id ?? primaryCutoffByPeriodId.get(expense.periodId)?.id ?? null, + cutoffPhase: expenseCutoffById.get(expense.id)?.cutoff_phase ?? "PRE", donationAmount: donationsByExpenseId.get(expense.id) ?? 0, donations: (donationRowsByExpenseId.get(expense.id) ?? []).map((donation) => ({ id: donation.id, @@ -310,13 +321,19 @@ export default async function DashboardPage() { })); const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({ + cutoffs: (periodCutoffsByPeriodId.get(period.id) ?? []).map((cutoff) => ({ + id: cutoff.id, + name: cutoff.name, + date: cutoff.date?.toISOString() ?? null, + periodId: cutoff.period_id + })), id: period.id, name: period.name, startsAt: period.startsAt.toISOString(), endsAt: period.endsAt.toISOString(), isCurrent: period.isCurrent, - cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air", - cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null + cutoffName: primaryCutoffByPeriodId.get(period.id)?.name ?? "Open Air", + cutoffDate: primaryCutoffByPeriodId.get(period.id)?.date?.toISOString() ?? null })); const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({ diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 335c902..2f9db10 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -36,6 +36,7 @@ import type { DashboardBudget, DashboardExpense, DashboardExpenseDonation, + DashboardPeriodCutoff, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; @@ -53,6 +54,7 @@ import { type BudgetColumnProps = { group: DashboardWorkingGroup; workingGroups: DashboardWorkingGroup[]; + cutoffs: DashboardPeriodCutoff[]; viewer: DashboardViewer; busy: boolean; approvalThreshold: number; @@ -75,6 +77,7 @@ type BudgetColumnProps = { amount: string; agId: string; budgetId: string; + cutoffId: string; cutoffPhase: "PRE" | "POST"; } ) => Promise; @@ -208,6 +211,7 @@ function getPaidSpend(expenses: DashboardExpense[]) { export function BudgetColumn({ group, workingGroups, + cutoffs, viewer, busy, approvalThreshold, @@ -240,7 +244,18 @@ export function BudgetColumn({ const [editingExpenseId, setEditingExpenseId] = useState(null); const [editingAssignedDonationId, setEditingAssignedDonationId] = useState(null); const [expenseDrafts, setExpenseDrafts] = useState< - Record + Record< + string, + { + title: string; + description: string; + amount: string; + agId: string; + budgetId: string; + cutoffId: string; + cutoffPhase: "PRE" | "POST"; + } + > >({}); const [assignedDonationDrafts, setAssignedDonationDrafts] = useState>({}); @@ -321,6 +336,7 @@ export function BudgetColumn({ amount: expense.amount.toFixed(2), agId: group.id, budgetId: expense.budgetId, + cutoffId: expense.cutoffId ?? cutoffs[0]?.id ?? "", cutoffPhase: expense.cutoffPhase }; } @@ -1062,6 +1078,8 @@ export function BudgetColumn({ const editGroup = workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group; const editBudgets = editGroup.budgets; + const selectedCutoff = + cutoffs.find((cutoff) => cutoff.id === draft.cutoffId) ?? cutoffs[0] ?? null; return ( @@ -1125,21 +1143,38 @@ export function BudgetColumn({ ))} - - updateExpenseDraft(expense, { - cutoffPhase: event.target.value as "PRE" | "POST" - }) - } - fullWidth - > - Pre Open Air - Post Open Air - + + updateExpenseDraft(expense, { cutoffId: event.target.value })} + fullWidth + disabled={cutoffs.length === 0} + > + {cutoffs.map((cutoff) => ( + + {cutoff.name} + + ))} + + + updateExpenseDraft(expense, { + cutoffPhase: event.target.value as "PRE" | "POST" + }) + } + fullWidth + > + {`Pre ${selectedCutoff?.name ?? "Open Air"}`} + {`Post ${selectedCutoff?.name ?? "Open Air"}`} + + - + + + {managementCutoffs.length > 0 ? ( + managementCutoffs.map((cutoff) => { + const draft = getCutoffDraft(cutoff); + const isEditing = editingCutoffId === cutoff.id; + + return ( + + {isEditing ? ( + + updateCutoffDraft(cutoff, { name: event.target.value })} + required + fullWidth + /> + updateCutoffDraft(cutoff, { date: event.target.value })} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + + + + + ) : ( + + + {cutoff.name} + + {cutoff.date ? dateFormatter.format(new Date(cutoff.date)) : "Kein Datum gesetzt"} + + + + + + + + )} + + ); + }) + ) : ( + + Für diesen Zeitraum gibt es noch keine Stichtage. + + )} + + + + + + Neuen Stichtag anlegen + + setCutoffForm((current) => ({ ...current, name: event.target.value }))} + required + fullWidth + disabled={!selectedPeriodForManagement} + /> + setCutoffForm((current) => ({ ...current, date: event.target.value }))} + InputLabelProps={{ shrink: true }} + fullWidth + disabled={!selectedPeriodForManagement} + /> + + + + ) : null; const actionCards = ( ) : null} - - setExpenseForm((current) => ({ - ...current, - cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"] - })) - } - required - fullWidth - > - {`Pre ${currentPeriod?.cutoffName ?? "Open Air"}`} - {`Post ${currentPeriod?.cutoffName ?? "Open Air"}`} - + + + setExpenseForm((current) => ({ + ...current, + cutoffId: event.target.value + })) + } + required + fullWidth + disabled={currentCutoffs.length === 0} + > + {currentCutoffs.map((cutoff) => ( + + {cutoff.name} + + ))} + + + setExpenseForm((current) => ({ + ...current, + cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"] + })) + } + required + fullWidth + > + {`Pre ${selectedExpenseCutoff?.name ?? "Open Air"}`} + {`Post ${selectedExpenseCutoff?.name ?? "Open Air"}`} + + expense.cutoffPhase === "PRE"); - const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST"); - const cutoffDate = currentPeriod?.cutoffDate ? new Date(currentPeriod.cutoffDate) : null; + const pre = allExpenses.filter( + (expense) => + expense.cutoffPhase === "PRE" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id) + ); + const post = allExpenses.filter( + (expense) => + expense.cutoffPhase === "POST" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id) + ); + const cutoffDate = primaryCurrentCutoff?.date ? new Date(primaryCurrentCutoff.date) : null; const preGeneralDonations = donations .filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate)) .reduce((sum, donation) => sum + donation.amount, 0); @@ -3384,14 +3627,14 @@ export function DashboardShell({ .reduce((sum, donation) => sum + donation.amount, 0); return [ { - label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`, + label: `Pre ${primaryCurrentCutoff?.name ?? "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: preAssignedDonations + preGeneralDonations }, { - label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`, + label: `Post ${primaryCurrentCutoff?.name ?? "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), @@ -3659,7 +3902,7 @@ export function DashboardShell({ sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} />