diff --git a/prisma/migrations/202605111200_finance_overview_cutoffs_donations/migration.sql b/prisma/migrations/202605111200_finance_overview_cutoffs_donations/migration.sql new file mode 100644 index 0000000..47dd917 --- /dev/null +++ b/prisma/migrations/202605111200_finance_overview_cutoffs_donations/migration.sql @@ -0,0 +1,42 @@ +CREATE TYPE "CutoffPhase" AS ENUM ('PRE', 'POST'); + +ALTER TABLE "accounting_periods" + ADD COLUMN "cutoff_name" TEXT NOT NULL DEFAULT 'Open Air', + ADD COLUMN "cutoff_date" TIMESTAMP(3); + +ALTER TABLE "expenses" + ADD COLUMN "cutoff_phase" "CutoffPhase" NOT NULL DEFAULT 'PRE'; + +CREATE TABLE "donations" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "amount" DECIMAL(10,2) NOT NULL, + "donated_at" TIMESTAMP(3) NOT NULL, + "period_id" TEXT NOT NULL, + "expense_id" TEXT, + "creator_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "donations_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "donations_period_id_idx" ON "donations"("period_id"); +CREATE INDEX "donations_expense_id_idx" ON "donations"("expense_id"); +CREATE INDEX "donations_creator_id_idx" ON "donations"("creator_id"); + +ALTER TABLE "donations" + ADD CONSTRAINT "donations_period_id_fkey" + FOREIGN KEY ("period_id") REFERENCES "accounting_periods"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "donations" + ADD CONSTRAINT "donations_expense_id_fkey" + FOREIGN KEY ("expense_id") REFERENCES "expenses"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "donations" + ADD CONSTRAINT "donations_creator_id_fkey" + FOREIGN KEY ("creator_id") REFERENCES "users"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f49d5ce..7900133 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,11 @@ enum ExpenseRecurrence { MONTHLY } +enum CutoffPhase { + PRE + POST +} + enum BudgetReleaseNotifyTarget { ALL_GROUP_USERS GROUP_MEMBERS_ONLY @@ -50,6 +55,7 @@ model User { createdExpenses Expense[] @relation("ExpenseCreator") approvals Approval[] uploadedDocuments ExpenseDocument[] + createdDonations Donation[] @relation("DonationCreator") auditLogs AuditLog[] pushSubscriptions PushSubscription[] createdAt DateTime @default(now()) @map("created_at") @@ -78,8 +84,11 @@ model AccountingPeriod { startsAt DateTime @map("starts_at") endsAt DateTime @map("ends_at") isCurrent Boolean @default(false) @map("is_current") + cutoffName String @default("Open Air") @map("cutoff_name") + cutoffDate DateTime? @map("cutoff_date") budgets Budget[] expenses Expense[] + donations Donation[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -139,6 +148,7 @@ model Expense { approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") recurrence ExpenseRecurrence @default(NONE) recurrenceStartAt DateTime? @map("recurrence_start_at") + cutoffPhase CutoffPhase @default(PRE) @map("cutoff_phase") paidAt DateTime? @map("paid_at") documentedAt DateTime? @map("documented_at") createdAt DateTime @default(now()) @map("created_at") @@ -149,10 +159,32 @@ model Expense { period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) approvals Approval[] documents ExpenseDocument[] + donations Donation[] @@map("expenses") } +model Donation { + id String @id @default(cuid()) + title String + description String? + amount Decimal @db.Decimal(10, 2) + donatedAt DateTime @map("donated_at") + periodId String @map("period_id") + expenseId String? @map("expense_id") + creatorId String @map("creator_id") + period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) + expense Expense? @relation(fields: [expenseId], references: [id], onDelete: SetNull) + creator User @relation("DonationCreator", fields: [creatorId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([periodId]) + @@index([expenseId]) + @@index([creatorId]) + @@map("donations") +} + model ExpenseDocument { id String @id @default(cuid()) expenseId String @map("expense_id") diff --git a/src/app/api/donations/route.ts b/src/app/api/donations/route.ts new file mode 100644 index 0000000..5ee9e6a --- /dev/null +++ b/src/app/api/donations/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; +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"; + +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; +} + +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 : undefined)), + amount: z.coerce.number().positive(), + donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"), + periodId: z.string().trim().min(1), + 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 POST(request: Request) { + 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 erfassen." }, { status: 403 }); + } + + 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 period = await prisma.accountingPeriod.findUnique({ + where: { id: parsed.data.periodId } + }); + + if (!period) { + return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 }); + } + + const expense = parsed.data.expenseId + ? await prisma.expense.findUnique({ + where: { id: parsed.data.expenseId } + }) + : null; + + if (parsed.data.expenseId && (!expense || expense.periodId !== period.id)) { + return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum." }, { status: 404 }); + } + + const donation = { + id: randomUUID(), + title: parsed.data.title, + description: parsed.data.description ?? null, + amount: parsed.data.amount, + donatedAt: parsed.data.donatedAt, + periodId: period.id, + expenseId: expense?.id ?? null, + creatorId: viewer.id, + createdAt: new Date() + }; + + await prisma.$executeRaw` + INSERT INTO donations (id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at, updated_at) + VALUES (${donation.id}, ${donation.title}, ${donation.description}, ${donation.amount}, ${donation.donatedAt}, + ${donation.periodId}, ${donation.expenseId}, ${donation.creatorId}, ${donation.createdAt}, ${donation.createdAt}) + `; + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "donation.create", + entityType: "donation", + entityId: donation.id, + entityLabel: donation.title, + summary: `Spende ${donation.title} wurde erfasst.`, + metadata: { + amount: donation.amount, + periodId: donation.periodId, + expenseId: donation.expenseId, + rollback: { + kind: "donation.create", + created: snapshotDonation(donation) + } + } + }); + + return NextResponse.json({ donation }); +} diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index c75098e..214ac17 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), + cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"), recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"), recurrenceStartAt: z .union([z.string().trim(), z.literal(""), z.null(), z.undefined()]) @@ -114,6 +115,7 @@ 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}`; if (needsManualApproval) { await notifyApprovalRequest( @@ -140,12 +142,13 @@ export async function POST(request: Request) { workingGroupId: parsed.data.agId, recurrence: parsed.data.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null, + cutoffPhase: parsed.data.cutoffPhase, approvalStatus: expense.approvalStatus, approvalThreshold, requiredApprovalTypes, rollback: { kind: "expense.create", - created: snapshotExpense(expense) + created: snapshotExpense({ ...expense, cutoffPhase: parsed.data.cutoffPhase }) } } }); diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index a64dfed..ae7bb1f 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -18,6 +18,8 @@ const CSV_HEADERS = [ "periodStartsAt", "periodEndsAt", "periodIsCurrent", + "cutoffName", + "cutoffDate", "budgetId", "budgetName", "userId", @@ -41,6 +43,9 @@ const CSV_HEADERS = [ "approvalType", "recurrence", "recurrenceStartAt", + "cutoffPhase", + "donatedAt", + "expenseId", "invoiceDate", "proofUrl", "storedFileName", @@ -194,6 +199,36 @@ export async function GET() { createdAt: appSettings.createdAt.toISOString() } as CsvRow); + const periodCutoffs = await prisma.$queryRaw< + { id: string; cutoff_name: string; cutoff_date: Date | null }[] + >`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`; + const expenseCutoffs = await prisma.$queryRaw< + { id: string; cutoff_phase: "PRE" | "POST" }[] + >`SELECT id, cutoff_phase FROM expenses`; + const donationRows = await prisma.$queryRaw< + { + id: string; + title: string; + description: string | null; + amount: unknown; + donated_at: Date; + period_id: string; + expense_id: string | null; + creator_id: string; + creator_name: string; + creator_username: string; + created_at: Date; + }[] + >` + SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.creator_id, + u.name AS creator_name, u.username AS creator_username, d.created_at + FROM donations d + JOIN users u ON u.id = d.creator_id + ORDER BY d.donated_at ASC + `; + const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period])); + const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase])); + for (const user of users) { rows.push({ recordType: "user", @@ -249,6 +284,7 @@ export async function GET() { } for (const period of accountingPeriods) { + const cutoff = periodCutoffById.get(period.id); rows.push({ recordType: "period", id: period.id, @@ -261,6 +297,8 @@ export async function GET() { periodStartsAt: period.startsAt.toISOString(), periodEndsAt: period.endsAt.toISOString(), periodIsCurrent: period.isCurrent ? "true" : "false", + cutoffName: cutoff?.cutoff_name ?? "Open Air", + cutoffDate: cutoff?.cutoff_date?.toISOString() ?? "", budgetId: "", budgetName: "", userId: "", @@ -442,6 +480,7 @@ export async function GET() { approvalType: "", recurrence: expense.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "", + cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE", invoiceDate: "", proofUrl: "", storedFileName: "", @@ -584,6 +623,27 @@ export async function GET() { } } + for (const donation of donationRows) { + rows.push({ + recordType: "donation", + id: donation.id, + parentId: donation.expense_id ?? donation.period_id, + parentType: donation.expense_id ? "expense" : "period", + periodId: donation.period_id, + userId: donation.creator_id, + userName: donation.creator_name, + username: donation.creator_username, + title: donation.title, + description: donation.description ?? "", + amount: Number(donation.amount).toFixed(2), + donatedAt: donation.donated_at.toISOString(), + expenseId: donation.expense_id ?? "", + createdAt: donation.created_at.toISOString(), + creatorName: donation.creator_name, + creatorUsername: donation.creator_username + }); + } + for (const auditLog of auditLogs) { rows.push({ recordType: "auditLog", diff --git a/src/app/api/periods/[id]/route.ts b/src/app/api/periods/[id]/route.ts index b5bd82f..b786ccf 100644 --- a/src/app/api/periods/[id]/route.ts +++ b/src/app/api/periods/[id]/route.ts @@ -16,7 +16,11 @@ type Context = { const periodSchema = z.object({ name: z.string().trim().min(2).max(80), startsAt: z.coerce.date(), - endsAt: z.coerce.date() + endsAt: z.coerce.date(), + cutoffName: z.string().trim().min(2).max(80).optional(), + cutoffDate: z + .union([z.coerce.date(), z.literal(""), z.null(), z.undefined()]) + .transform((value) => (value instanceof Date ? value : null)) }); export async function PATCH(request: Request, { params }: Context) { @@ -76,6 +80,16 @@ export async function PATCH(request: Request, { params }: Context) { endsAt: parsed.data.endsAt } }); + await prisma.$executeRaw` + UPDATE accounting_periods + SET cutoff_name = ${parsed.data.cutoffName ?? "Open Air"}, + cutoff_date = ${parsed.data.cutoffDate} + WHERE id = ${id} + `; + const cutoffRows = await prisma.$queryRaw< + { cutoff_name: string; cutoff_date: Date | null }[] + >`SELECT cutoff_name, cutoff_date FROM accounting_periods WHERE id = ${id}`; + const cutoff = cutoffRows[0] ?? { cutoff_name: "Open Air", cutoff_date: null }; await createAuditLog(prisma, { actorId: viewer.id, @@ -90,10 +104,16 @@ export async function PATCH(request: Request, { params }: Context) { metadata: { startsAt: updatedPeriod.startsAt.toISOString(), endsAt: updatedPeriod.endsAt.toISOString(), + cutoffName: cutoff.cutoff_name, + cutoffDate: cutoff.cutoff_date?.toISOString() ?? null, rollback: { kind: "period.update", previous: snapshotPeriod(period), - next: snapshotPeriod(updatedPeriod) + next: snapshotPeriod({ + ...updatedPeriod, + cutoffName: cutoff.cutoff_name, + cutoffDate: cutoff.cutoff_date + }) } } }); diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index e4999c0..4fc07d4 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -78,10 +78,6 @@ export async function PATCH(request: Request, { params }: Context) { ? parsed.data.workingGroupId : null; - if (parsed.data.role === "MEMBER" && !workingGroupId) { - return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 }); - } - if (workingGroupId) { const workingGroup = await prisma.workingGroup.findUnique({ where: { id: workingGroupId } diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 67c3bf8..7f43b4d 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -72,10 +72,6 @@ export async function POST(request: Request) { null ); - if (parsed.data.role === "MEMBER" && !workingGroupId) { - return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 }); - } - if (workingGroupId) { const workingGroup = await prisma.workingGroup.findUnique({ where: { id: workingGroupId } diff --git a/src/app/page.tsx b/src/app/page.tsx index f9d4e8b..195521e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { getRollbackMetadata } from "@/lib/audit-log"; import type { DashboardAccountingPeriod, DashboardAuditLog, + DashboardDonation, DashboardManagedUser, DashboardSettings, DashboardViewer, @@ -141,6 +142,45 @@ 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`; + const expenseCutoffs = await prisma.$queryRaw< + { id: string; cutoff_phase: "PRE" | "POST" }[] + >`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`; + const donationRows = await prisma.$queryRaw< + { + id: string; + title: string; + description: string | null; + amount: unknown; + donated_at: Date; + period_id: string; + expense_id: string | null; + created_at: Date; + creator_id: string; + creator_name: string; + }[] + >` + 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 + FROM donations d + JOIN users u ON u.id = d.creator_id + 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 donationsByExpenseId = new Map(); + for (const donation of donationRows) { + if (donation.expense_id) { + donationsByExpenseId.set( + donation.expense_id, + (donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount) + ); + } + } + const serializedViewer: DashboardViewer = { id: viewer.id, name: viewer.username, @@ -197,6 +237,13 @@ export default async function DashboardPage() { approvalStatus: expense.approvalStatus, recurrence: expense.recurrence, recurrenceStartAt, + cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE", + donationAmount: donationsByExpenseId.get(expense.id) ?? 0, + netPeriodAmount: Math.max( + 0, + getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) - + (donationsByExpenseId.get(expense.id) ?? 0) + ), paidAt: expense.paidAt?.toISOString() ?? null, documentedAt: expense.documentedAt?.toISOString() ?? null, documents: expense.documents.map((document) => ({ @@ -249,7 +296,24 @@ export default async function DashboardPage() { name: period.name, startsAt: period.startsAt.toISOString(), endsAt: period.endsAt.toISOString(), - isCurrent: period.isCurrent + isCurrent: period.isCurrent, + cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air", + cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null + })); + + const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({ + id: donation.id, + title: donation.title, + description: donation.description, + amount: Number(donation.amount), + donatedAt: donation.donated_at.toISOString(), + periodId: donation.period_id, + expenseId: donation.expense_id, + createdAt: donation.created_at.toISOString(), + creator: { + id: donation.creator_id, + name: donation.creator_name + } })); const serializedSettings: DashboardSettings = serializeAppSettings(appSettings); @@ -282,6 +346,7 @@ export default async function DashboardPage() { accountingPeriods={serializedPeriods} currentPeriodId={currentPeriod.id} settings={serializedSettings} + donations={serializedDonations} /> ); } diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index e7e6de1..5de9463 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -127,15 +127,15 @@ function StatusChips({ expense }: { expense: DashboardExpense }) { } function getApprovedSpend(expenses: DashboardExpense[]) { - return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), 0); + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0); } function getPendingSpend(expenses: DashboardExpense[]) { - return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0); + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0); } function getPaidSpend(expenses: DashboardExpense[]) { - return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0); + return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0); } export function BudgetColumn({ @@ -166,11 +166,11 @@ export function BudgetColumn({ const [proofFileDrafts, setProofFileDrafts] = useState>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); - const budgetCardWidth = 352; - const desktopBudgetGap = 16; + const budgetCardWidth = 318; + const desktopBudgetGap = 14; const desktopBudgetListWidth = group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap; - const groupCardWidth = Math.max(desktopBudgetListWidth + 64, 456); + const groupCardWidth = Math.max(desktopBudgetListWidth + 58, 410); const canEditBudgets = canManageBudgets(viewer.role); useEffect(() => { @@ -766,10 +766,15 @@ export function BudgetColumn({ {isRecurringSeries ? expense.occurrenceCount > 0 - ? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}` + ? `${formatCurrency(expense.netPeriodAmount)} netto 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.amount)} von ${expense.creator.name}`} + : `${formatCurrency(expense.netPeriodAmount)} netto von ${expense.creator.name}`} + {expense.donationAmount > 0 ? ( + + {`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`} + + ) : null} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 3ee9ff0..4aaf008 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -44,6 +44,7 @@ import { ColorPickerField } from "@/components/dashboard/color-picker-field"; import type { DashboardAccountingPeriod, DashboardAuditLog, + DashboardDonation, DashboardManagedUser, DashboardSettings, DashboardViewer, @@ -66,6 +67,7 @@ type DashboardShellProps = { accountingPeriods: DashboardAccountingPeriod[]; currentPeriodId: string; settings: DashboardSettings; + donations: DashboardDonation[]; }; type ExpenseFormState = { @@ -76,6 +78,7 @@ type ExpenseFormState = { budgetId: string; recurrence: "NONE" | "MONTHLY"; recurrenceStartAt: string; + cutoffPhase: "PRE" | "POST"; }; type BudgetFormState = { @@ -91,6 +94,15 @@ type BudgetReleaseFormState = { releasedAmount: string; }; +type DonationFormState = { + title: string; + description: string; + amount: string; + donatedAt: string; + target: "GENERAL" | "EXPENSE"; + expenseId: string; +}; + type WorkingGroupFormState = { name: string; }; @@ -120,6 +132,8 @@ type PeriodEditFormState = { name: string; startsAt: string; endsAt: string; + cutoffName: string; + cutoffDate: string; }; type OrgaSettingsDraft = { @@ -172,9 +186,10 @@ function sortManagedUsersList(users: DashboardManagedUser[]) { } -type MobileSection = "overview" | "actions"; +type MobileSection = "overview" | "finance" | "actions"; type MobileAction = | "expense" + | "donation" | "budgetRelease" | "workingGroup" | "budget" @@ -184,7 +199,9 @@ type MobileAction = | "approvalThreshold" | "users" | "logs"; -type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs"; +type FinanceViewMode = "monthly" | "yearly" | "cutoff"; +type FinancePresentation = "charts" | "table"; +type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs"; const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" @@ -234,14 +251,18 @@ function getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined return { name: "", startsAt: "", - endsAt: "" + endsAt: "", + cutoffName: "Open Air", + cutoffDate: "" }; } return { name: period.name, startsAt: toDateInputValue(period.startsAt), - endsAt: toDateInputValue(period.endsAt) + endsAt: toDateInputValue(period.endsAt), + cutoffName: period.cutoffName || "Open Air", + cutoffDate: period.cutoffDate ? toDateInputValue(period.cutoffDate) : "" }; } @@ -295,7 +316,8 @@ export function DashboardShell({ auditLogs, accountingPeriods, currentPeriodId, - settings + settings, + donations }: DashboardShellProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -313,7 +335,8 @@ export function DashboardShell({ const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0]; const approvalThreshold = settings.approvalThreshold; const desktopSections = [ - { value: "overview" as const, label: "\u00dcbersicht" }, + { value: "overview" as const, label: "AG-\u00dcbersicht" }, + { value: "finance" as const, label: "Finanz\u00fcbersicht" }, ...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []), ...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []), ...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []), @@ -321,6 +344,7 @@ export function DashboardShell({ ]; const mobileActions = [ { value: "expense" as const, label: "Neue Ausgabe" }, + ...(canManagePeriods ? [{ value: "donation" as const, label: "Spende erfassen" }] : []), ...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []), ...(canManageBudgets(viewer.role) ? [ @@ -354,7 +378,16 @@ export function DashboardShell({ agId: defaultEditableGroup?.id ?? "", budgetId: defaultBudget?.id ?? "", recurrence: "NONE", - recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), + cutoffPhase: "PRE" + }); + const [donationForm, setDonationForm] = useState({ + title: "", + description: "", + amount: "", + donatedAt: toDateInputValue(new Date().toISOString()), + target: "GENERAL", + expenseId: "" }); const [budgetForm, setBudgetForm] = useState({ workingGroupId: visibleGroups[0]?.id ?? "", @@ -374,13 +407,15 @@ export function DashboardShell({ username: "", password: "", role: "MEMBER", - workingGroupId: visibleGroups[0]?.id ?? "" + workingGroupId: "" }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); const [mobileSection, setMobileSection] = useState("overview"); const [selectedMobileAction, setSelectedMobileAction] = useState("expense"); const [desktopSection, setDesktopSection] = useState("overview"); + const [financeViewMode, setFinanceViewMode] = useState("monthly"); + const [financePresentation, setFinancePresentation] = useState("charts"); const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? ""); const [focusedBudgetId, setFocusedBudgetId] = useState(null); @@ -576,7 +611,7 @@ export function DashboardShell({ if (!groupStillExists) { setBudgetForm((current) => ({ ...current, - workingGroupId: visibleGroups[0]?.id ?? "" + workingGroupId: "" })); } }, [budgetForm.workingGroupId, visibleGroups]); @@ -667,12 +702,13 @@ export function DashboardShell({ }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]); useEffect(() => { - const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId); + const groupStillExists = + userForm.workingGroupId === "" || visibleGroups.some((group) => group.id === userForm.workingGroupId); if (!groupStillExists) { setUserForm((current) => ({ ...current, - workingGroupId: visibleGroups[0]?.id ?? "" + workingGroupId: "" })); } }, [userForm.workingGroupId, visibleGroups]); @@ -711,7 +747,7 @@ export function DashboardShell({ null; const selectedBudgetReleasePaidAmount = selectedBudgetReleaseBudget?.expenses.reduce( - (sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), + (sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0 ) ?? 0; const selectedPeriodForManagement = @@ -720,7 +756,14 @@ export function DashboardShell({ selectedPeriodForManagement !== null && (periodEditForm.name.trim() !== selectedPeriodForManagement.name || periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) || - periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt)); + periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt) || + periodEditForm.cutoffName.trim() !== selectedPeriodForManagement.cutoffName || + periodEditForm.cutoffDate !== (selectedPeriodForManagement.cutoffDate ? toDateInputValue(selectedPeriodForManagement.cutoffDate) : "")); + + const allExpenses = useMemo( + () => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)), + [visibleGroups] + ); function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft { return userDrafts[user.id] ?? { @@ -755,7 +798,7 @@ export function DashboardShell({ (groupSum, budget) => groupSum + budget.expenses.reduce( - (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), + (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0 ), 0 @@ -764,7 +807,7 @@ export function DashboardShell({ (groupSum, budget) => groupSum + budget.expenses.reduce( - (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), + (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0 ), 0 @@ -796,6 +839,23 @@ export function DashboardShell({ ); }, [visibleGroups]); + const generalDonationTotal = useMemo( + () => donations.reduce((sum, donation) => sum + (donation.expenseId ? 0 : donation.amount), 0), + [donations] + ); + const assignedDonationTotal = useMemo( + () => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0), + [donations] + ); + const preCutoffExpenses = useMemo( + () => allExpenses.reduce((sum, expense) => sum + (expense.cutoffPhase === "PRE" ? expense.netPeriodAmount : 0), 0), + [allExpenses] + ); + const paidTotal = useMemo( + () => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0), + [allExpenses] + ); + async function runAction( task: () => Promise, successMessage: string | ((result: T) => string) @@ -852,7 +912,8 @@ export function DashboardShell({ agId: expenseForm.agId, budgetId: expenseForm.budgetId, recurrence: expenseForm.recurrence, - recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "" + recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "", + cutoffPhase: expenseForm.cutoffPhase }) }) ); @@ -867,7 +928,8 @@ export function DashboardShell({ agId: resetGroup, budgetId: resetBudget, recurrence: "NONE", - recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), + cutoffPhase: "PRE" }); }, "Ausgabe wurde gespeichert."); } @@ -1054,6 +1116,48 @@ export function DashboardShell({ }, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`); } + async function handleCreateDonation(event: FormEvent) { + event.preventDefault(); + + if (!currentPeriod) { + setMessage({ type: "error", text: "Bitte zuerst einen aktuellen Zeitraum auswählen." }); + return; + } + + if (donationForm.target === "EXPENSE" && !donationForm.expenseId) { + setMessage({ type: "error", text: "Bitte die Ausgabe auswählen, der die Spende zugeordnet werden soll." }); + return; + } + + await runAction(async () => { + await parseResponse( + await fetch("/api/donations", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + title: donationForm.title, + description: donationForm.description, + amount: donationForm.amount, + donatedAt: donationForm.donatedAt, + periodId: currentPeriod.id, + expenseId: donationForm.target === "EXPENSE" ? donationForm.expenseId : "" + }) + }) + ); + + setDonationForm({ + title: "", + description: "", + amount: "", + donatedAt: toDateInputValue(new Date().toISOString()), + target: "GENERAL", + expenseId: "" + }); + }, "Spende wurde erfasst."); + } + async function handleDeleteBudget(budgetId: string) { await runAction(async () => { await parseResponse( @@ -1207,7 +1311,7 @@ export function DashboardShell({ username: "", password: "", role: "MEMBER", - workingGroupId: visibleGroups[0]?.id ?? "" + workingGroupId: "" }); return { @@ -1692,6 +1796,44 @@ 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} + /> + + + + + @@ -1798,6 +1940,12 @@ export function DashboardShell({ + {viewer.role === "MEMBER" && !viewer.workingGroupId ? ( + + Du bist noch keiner AG zugeordnet. Du kannst dich anmelden, aber Ausgaben erst erfassen, wenn dir eine AG zugewiesen wurde. + + ) : null} + ) : null} + + setExpenseForm((current) => ({ + ...current, + cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"] + })) + } + required + fullWidth + > + {`Pre ${currentPeriod?.cutoffName ?? "Open Air"}`} + {`Post ${currentPeriod?.cutoffName ?? "Open Air"}`} + ) : null} + {canManagePeriods && (isCompactLayout ? selectedMobileAction === "donation" : desktopSection === "overview") ? ( + + + + + + Spende erfassen + + + Allgemeine Spenden zählen global, zugeordnete Spenden entlasten direkt eine Ausgabe. + + + + + setDonationForm((current) => ({ ...current, title: event.target.value }))} + required + fullWidth + /> + setDonationForm((current) => ({ ...current, description: event.target.value }))} + fullWidth + multiline + minRows={2} + /> + setDonationForm((current) => ({ ...current, amount: event.target.value }))} + required + fullWidth + /> + setDonationForm((current) => ({ ...current, donatedAt: event.target.value }))} + InputLabelProps={{ shrink: true }} + required + fullWidth + /> + + setDonationForm((current) => ({ + ...current, + target: event.target.value as DonationFormState["target"], + expenseId: "" + })) + } + fullWidth + > + Allgemein + 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)} + + ))} + + ) : null} + + + + + + + ) : null} + {canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? ( @@ -2249,16 +2503,15 @@ export function DashboardShell({ } fullWidth disabled={visibleGroups.length === 0} - required={userForm.role === "MEMBER"} - helperText={ - visibleGroups.length === 0 - ? "Lege zuerst eine AG an." - : userForm.role === "MEMBER" - ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." - : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." - } - > - {userForm.role !== "MEMBER" ? Ohne AG : null} + helperText={ + visibleGroups.length === 0 + ? "Lege zuerst eine AG an." + : userForm.role === "MEMBER" + ? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen." + : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." + } + > + Ohne AG {visibleGroups.map((group) => ( {group.name} @@ -2453,16 +2706,15 @@ export function DashboardShell({ onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })} fullWidth disabled={visibleGroups.length === 0} - required={draft.role === "MEMBER"} - helperText={ - visibleGroups.length === 0 - ? "Lege zuerst eine AG an." - : draft.role === "MEMBER" - ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." - : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." - } - > - {draft.role !== "MEMBER" ? Ohne AG : null} + helperText={ + visibleGroups.length === 0 + ? "Lege zuerst eine AG an." + : draft.role === "MEMBER" + ? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen." + : "Optional: Verwaltungsrollen können einer AG zugeordnet werden." + } + > + Ohne AG {visibleGroups.map((group) => ( {group.name} @@ -2780,6 +3032,167 @@ export function DashboardShell({ ); + const financeRows = (() => { + if (financeViewMode === "monthly") { + const rows = new Map(); + for (const expense of allExpenses) { + const date = new Date(expense.createdAt); + const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; + const row = rows.get(key) ?? { + label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date), + planned: 0, + approved: 0, + paid: 0, + donations: 0 + }; + if (expense.approvalStatus === "PENDING") row.planned += expense.netPeriodAmount; + if (expense.approvalStatus === "APPROVED") row.approved += expense.netPeriodAmount; + if (expense.paidAt) row.paid += expense.netPeriodAmount; + rows.set(key, row); + } + for (const donation of donations) { + const date = new Date(donation.donatedAt); + const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; + const row = rows.get(key) ?? { + label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date), + planned: 0, + approved: 0, + paid: 0, + donations: 0 + }; + row.donations += donation.amount; + rows.set(key, row); + } + return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, row]) => row); + } + + if (financeViewMode === "cutoff") { + const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE"); + const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST"); + 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 + }, + { + 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 + } + ]; + } + + return [ + { + label: currentPeriod?.name ?? "Jahresübersicht", + planned: totals.pending, + approved: totals.approved, + paid: paidTotal, + donations: generalDonationTotal + assignedDonationTotal + } + ]; + })(); + + const financeOverviewContent = ( + + + + + + setFinanceViewMode(event.target.value as FinanceViewMode)} + fullWidth + > + Monatsübersichten + Jahresübersicht + Jahresübersicht Pre/Post + + setFinancePresentation(event.target.value as FinancePresentation)} + fullWidth + > + Grafisch + Tabellarisch + + + + + + + + + + + + + {financeRows.map((row) => { + const maxValue = Math.max(row.planned, row.approved, row.paid, row.donations, 1); + return ( + + + + + {row.label} + + {(["planned", "approved", "paid", "donations"] as const).map((key) => { + const label = + key === "planned" ? "Geplant" : key === "approved" ? "Freigegeben" : key === "paid" ? "Bezahlt" : "Spenden"; + const value = row[key]; + return ( + + + {label} + + {currencyFormatter.format(value)} + + + {financePresentation === "charts" ? ( + + + + ) : null} + + ); + })} + + + + ); + })} + + + ); + const desktopSectionContent = desktopSection === "overview" ? ( {actionCards} {overviewContent} + ) : desktopSection === "finance" ? ( + financeOverviewContent ) : desktopSection === "periods" ? ( {canManagePeriods ? ( @@ -2902,6 +3317,10 @@ export function DashboardShell({ label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} /> + setMobileSection(nextValue)} variant="fullWidth" > - + + - {mobileSection === "overview" ? overviewContent : actionCards} + {mobileSection === "overview" ? overviewContent : mobileSection === "finance" ? financeOverviewContent : actionCards} ) : ( {desktopSectionContent} diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts index e5ff85a..be2e456 100644 --- a/src/lib/audit-snapshots.ts +++ b/src/lib/audit-snapshots.ts @@ -8,13 +8,18 @@ export function snapshotWorkingGroup(workingGroup: Pick) { +export function snapshotPeriod(period: Pick & { + cutoffName?: string; + cutoffDate?: Date | null; +}) { return { id: period.id, name: period.name, startsAt: period.startsAt.toISOString(), endsAt: period.endsAt.toISOString(), isCurrent: period.isCurrent, + cutoffName: period.cutoffName, + cutoffDate: period.cutoffDate?.toISOString() ?? null, createdAt: period.createdAt.toISOString() }; } @@ -63,7 +68,9 @@ export function snapshotExpense( | "createdAt" | "paidAt" | "documentedAt" - > + > & { + cutoffPhase?: "PRE" | "POST"; + } ) { return { id: expense.id, @@ -77,12 +84,39 @@ export function snapshotExpense( approvalStatus: expense.approvalStatus, recurrence: expense.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null, + cutoffPhase: expense.cutoffPhase ?? "PRE", createdAt: expense.createdAt.toISOString(), paidAt: expense.paidAt?.toISOString() ?? null, documentedAt: expense.documentedAt?.toISOString() ?? null }; } +export function snapshotDonation( + donation: { + id: string; + title: string; + description: string | null; + amount: unknown; + donatedAt: Date; + periodId: string; + expenseId: string | null; + creatorId: string; + createdAt: Date; + } +) { + return { + id: donation.id, + title: donation.title, + description: donation.description, + amount: Number(donation.amount), + donatedAt: donation.donatedAt.toISOString(), + periodId: donation.periodId, + expenseId: donation.expenseId, + creatorId: donation.creatorId, + createdAt: donation.createdAt.toISOString() + }; +} + export function snapshotApproval(approval: Pick) { return { id: approval.id, diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index ac17074..3e94c1e 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -3,6 +3,7 @@ import type { ApprovalStatusValue, ApprovalTypeValue, BudgetReleaseNotifyTargetValue, + CutoffPhaseValue, ExpenseRecurrenceValue } from "@/lib/domain"; @@ -12,6 +13,8 @@ export type DashboardAccountingPeriod = { startsAt: string; endsAt: string; isCurrent: boolean; + cutoffName: string; + cutoffDate: string | null; }; export type DashboardViewer = { @@ -68,6 +71,9 @@ export type DashboardExpense = { approvalStatus: ApprovalStatusValue; recurrence: ExpenseRecurrenceValue; recurrenceStartAt: string | null; + cutoffPhase: CutoffPhaseValue; + donationAmount: number; + netPeriodAmount: number; paidAt: string | null; documentedAt: string | null; documents: DashboardExpenseDocument[]; @@ -79,6 +85,21 @@ export type DashboardExpense = { approvals: DashboardApproval[]; }; +export type DashboardDonation = { + id: string; + title: string; + description: string | null; + amount: number; + donatedAt: string; + periodId: string; + expenseId: string | null; + createdAt: string; + creator: { + id: string; + name: string; + }; +}; + export type DashboardSettings = { approvalThreshold: number; requiredApprovalTypes: ApprovalTypeValue[]; diff --git a/src/lib/domain.ts b/src/lib/domain.ts index a13d4a0..7f1aeeb 100644 --- a/src/lib/domain.ts +++ b/src/lib/domain.ts @@ -18,6 +18,7 @@ export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number]; export type ApprovalStatusValue = "PENDING" | "APPROVED"; export type ExpenseRecurrenceValue = "NONE" | "MONTHLY"; +export type CutoffPhaseValue = "PRE" | "POST"; export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY"; export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) { @@ -57,6 +58,10 @@ export function recurrenceLabel(recurrence: ExpenseRecurrenceValue) { } } +export function cutoffPhaseLabel(phase: CutoffPhaseValue, cutoffName = "Open Air") { + return phase === "PRE" ? `Pre ${cutoffName}` : `Post ${cutoffName}`; +} + export function hasAdministrativeAccess(role: AppRole) { return role === "BOARD" || role === "ORGA" || role === "FINANCE"; }