Compare commits
16 Commits
dbb60cac4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1209265b01 | |||
| 973b22ee10 | |||
| cee7081da6 | |||
| 5591d10d96 | |||
| 08df13c044 | |||
| d229a6b439 | |||
| ae3a00a298 | |||
| a527a840ee | |||
| c738b35d06 | |||
| c93616f09e | |||
| 57d1e9247b | |||
| 208520cff8 | |||
| 3e5ac7730d | |||
| 0d72cfb144 | |||
| 0bdb6f553b | |||
| f525279f2b |
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -31,6 +31,11 @@ enum ExpenseRecurrence {
|
|||||||
MONTHLY
|
MONTHLY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CutoffPhase {
|
||||||
|
PRE
|
||||||
|
POST
|
||||||
|
}
|
||||||
|
|
||||||
enum BudgetReleaseNotifyTarget {
|
enum BudgetReleaseNotifyTarget {
|
||||||
ALL_GROUP_USERS
|
ALL_GROUP_USERS
|
||||||
GROUP_MEMBERS_ONLY
|
GROUP_MEMBERS_ONLY
|
||||||
@@ -50,6 +55,7 @@ model User {
|
|||||||
createdExpenses Expense[] @relation("ExpenseCreator")
|
createdExpenses Expense[] @relation("ExpenseCreator")
|
||||||
approvals Approval[]
|
approvals Approval[]
|
||||||
uploadedDocuments ExpenseDocument[]
|
uploadedDocuments ExpenseDocument[]
|
||||||
|
createdDonations Donation[] @relation("DonationCreator")
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -78,14 +84,32 @@ model AccountingPeriod {
|
|||||||
startsAt DateTime @map("starts_at")
|
startsAt DateTime @map("starts_at")
|
||||||
endsAt DateTime @map("ends_at")
|
endsAt DateTime @map("ends_at")
|
||||||
isCurrent Boolean @default(false) @map("is_current")
|
isCurrent Boolean @default(false) @map("is_current")
|
||||||
|
cutoffName String @default("Open Air") @map("cutoff_name")
|
||||||
|
cutoffDate DateTime? @map("cutoff_date")
|
||||||
|
cutoffs PeriodCutoff[]
|
||||||
budgets Budget[]
|
budgets Budget[]
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
|
donations Donation[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("accounting_periods")
|
@@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 {
|
model AppSettings {
|
||||||
id String @id @default("global")
|
id String @id @default("global")
|
||||||
approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold")
|
approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold")
|
||||||
@@ -139,6 +163,8 @@ model Expense {
|
|||||||
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
|
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
|
||||||
recurrence ExpenseRecurrence @default(NONE)
|
recurrence ExpenseRecurrence @default(NONE)
|
||||||
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
||||||
|
cutoffId String? @map("cutoff_id")
|
||||||
|
cutoffPhase CutoffPhase @default(PRE) @map("cutoff_phase")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
documentedAt DateTime? @map("documented_at")
|
documentedAt DateTime? @map("documented_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -147,12 +173,35 @@ model Expense {
|
|||||||
workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade)
|
workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade)
|
||||||
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict)
|
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict)
|
||||||
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
||||||
|
cutoff PeriodCutoff? @relation(fields: [cutoffId], references: [id], onDelete: SetNull)
|
||||||
approvals Approval[]
|
approvals Approval[]
|
||||||
documents ExpenseDocument[]
|
documents ExpenseDocument[]
|
||||||
|
donations Donation[]
|
||||||
|
|
||||||
@@map("expenses")
|
@@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 {
|
model ExpenseDocument {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
expenseId String @map("expense_id")
|
expenseId String @map("expense_id")
|
||||||
|
|||||||
22
public/sw.js
22
public/sw.js
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "rave-budget-control-v1";
|
const CACHE_NAME = "rave-budget-control-v2";
|
||||||
const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"];
|
const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -14,7 +14,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) =>
|
caches.keys().then((cacheNames) =>
|
||||||
Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)))
|
Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)))
|
||||||
)
|
).then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,10 +66,24 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
|
clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
|
||||||
for (const client of clientList) {
|
const sameOriginClients = clientList.filter((client) => {
|
||||||
const clientUrl = new URL(client.url);
|
const clientUrl = new URL(client.url);
|
||||||
|
return clientUrl.origin === targetUrl.origin;
|
||||||
|
});
|
||||||
|
|
||||||
if (clientUrl.origin === targetUrl.origin && "focus" in client) {
|
const exactClient = sameOriginClients.find((client) => new URL(client.url).href === targetUrl.href);
|
||||||
|
const targetClient = exactClient ?? sameOriginClients[0];
|
||||||
|
|
||||||
|
if (targetClient && "focus" in targetClient) {
|
||||||
|
if ("navigate" in targetClient && new URL(targetClient.url).href !== targetUrl.href) {
|
||||||
|
await targetClient.navigate(targetUrl.href).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetClient.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of sameOriginClients) {
|
||||||
|
if ("focus" in client) {
|
||||||
if ("navigate" in client) {
|
if ("navigate" in client) {
|
||||||
await client.navigate(targetUrl.href).catch(() => null);
|
await client.navigate(targetUrl.href).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|||||||
193
src/app/api/donations/[id]/route.ts
Normal file
193
src/app/api/donations/[id]/route.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { snapshotDonation } from "@/lib/audit-snapshots";
|
||||||
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
|
import { canManageBudgets } from "@/lib/domain";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DonationRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
donated_at: Date;
|
||||||
|
period_id: string;
|
||||||
|
expense_id: string | null;
|
||||||
|
creator_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDateInput(value: string) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
|
||||||
|
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDonation(row: DonationRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
amount: row.amount,
|
||||||
|
donatedAt: row.donated_at,
|
||||||
|
periodId: row.period_id,
|
||||||
|
expenseId: row.expense_id,
|
||||||
|
creatorId: row.creator_id,
|
||||||
|
createdAt: row.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDonation(id: string) {
|
||||||
|
const rows = await prisma.$queryRaw<DonationRow[]>`
|
||||||
|
SELECT id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at
|
||||||
|
FROM donations
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const donationSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(2).max(120),
|
||||||
|
description: z
|
||||||
|
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||||
|
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null)),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
|
||||||
|
expenseId: z
|
||||||
|
.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||||
|
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null))
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.donatedAt === "invalid") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Bitte ein gültiges Spendendatum angeben.",
|
||||||
|
path: ["donatedAt"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageBudgets(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden bearbeiten." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const donation = await getDonation(id);
|
||||||
|
|
||||||
|
if (!donation) {
|
||||||
|
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = donationSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success || !(parsed.data.donatedAt instanceof Date)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.success ? "Bitte Spendendaten korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Spendendaten korrekt ausfüllen." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = parsed.data.expenseId
|
||||||
|
? await prisma.expense.findUnique({
|
||||||
|
where: { id: parsed.data.expenseId }
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parsed.data.expenseId && (!expense || expense.periodId !== donation.period_id)) {
|
||||||
|
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum der Spende." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE donations
|
||||||
|
SET title = ${parsed.data.title},
|
||||||
|
description = ${parsed.data.description},
|
||||||
|
amount = ${parsed.data.amount},
|
||||||
|
donated_at = ${parsed.data.donatedAt},
|
||||||
|
expense_id = ${expense?.id ?? null},
|
||||||
|
updated_at = ${new Date()}
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updatedDonation = await getDonation(id);
|
||||||
|
|
||||||
|
await createAuditLog(prisma, {
|
||||||
|
actorId: viewer.id,
|
||||||
|
action: "donation.update",
|
||||||
|
entityType: "donation",
|
||||||
|
entityId: id,
|
||||||
|
entityLabel: parsed.data.title,
|
||||||
|
summary: `Spende ${parsed.data.title} wurde bearbeitet.`,
|
||||||
|
metadata: {
|
||||||
|
rollback: {
|
||||||
|
kind: "donation.update",
|
||||||
|
previous: snapshotDonation(normalizeDonation(donation)),
|
||||||
|
next: updatedDonation ? snapshotDonation(normalizeDonation(updatedDonation)) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageBudgets(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden löschen." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const donation = await getDonation(id);
|
||||||
|
|
||||||
|
if (!donation) {
|
||||||
|
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRaw`DELETE FROM donations WHERE id = ${id}`;
|
||||||
|
|
||||||
|
await createAuditLog(prisma, {
|
||||||
|
actorId: viewer.id,
|
||||||
|
action: "donation.delete",
|
||||||
|
entityType: "donation",
|
||||||
|
entityId: id,
|
||||||
|
entityLabel: donation.title,
|
||||||
|
summary: `Spende ${donation.title} wurde gelöscht.`,
|
||||||
|
metadata: {
|
||||||
|
rollback: {
|
||||||
|
kind: "donation.delete",
|
||||||
|
deleted: snapshotDonation(normalizeDonation(donation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
123
src/app/api/donations/route.ts
Normal file
123
src/app/api/donations/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
@@ -12,6 +13,118 @@ 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),
|
||||||
|
cutoffId: z.string().trim().min(1).nullable().optional(),
|
||||||
|
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 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}
|
||||||
|
`;
|
||||||
|
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_id = ${cutoffId}, 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) {
|
export async function DELETE(_: Request, { params }: Context) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const viewer = await getCurrentViewer();
|
const viewer = await getCurrentViewer();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const expenseSchema = z
|
|||||||
amount: z.coerce.number().positive(),
|
amount: z.coerce.number().positive(),
|
||||||
agId: z.string().trim().min(1),
|
agId: z.string().trim().min(1),
|
||||||
budgetId: 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"),
|
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
|
||||||
recurrenceStartAt: z
|
recurrenceStartAt: z
|
||||||
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
|
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
|
||||||
@@ -92,6 +94,24 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG." }, { status: 404 });
|
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 approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
||||||
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
|
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
|
||||||
const recurrenceStartAt =
|
const recurrenceStartAt =
|
||||||
@@ -114,6 +134,11 @@ export async function POST(request: Request) {
|
|||||||
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE expenses
|
||||||
|
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
|
||||||
|
WHERE id = ${expense.id}
|
||||||
|
`;
|
||||||
|
|
||||||
if (needsManualApproval) {
|
if (needsManualApproval) {
|
||||||
await notifyApprovalRequest(
|
await notifyApprovalRequest(
|
||||||
@@ -140,12 +165,13 @@ export async function POST(request: Request) {
|
|||||||
workingGroupId: parsed.data.agId,
|
workingGroupId: parsed.data.agId,
|
||||||
recurrence: parsed.data.recurrence,
|
recurrence: parsed.data.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
|
cutoffPhase: parsed.data.cutoffPhase,
|
||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
requiredApprovalTypes,
|
requiredApprovalTypes,
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.create",
|
kind: "expense.create",
|
||||||
created: snapshotExpense(expense)
|
created: snapshotExpense({ ...expense, cutoffPhase: parsed.data.cutoffPhase })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const CSV_HEADERS = [
|
|||||||
"periodStartsAt",
|
"periodStartsAt",
|
||||||
"periodEndsAt",
|
"periodEndsAt",
|
||||||
"periodIsCurrent",
|
"periodIsCurrent",
|
||||||
|
"cutoffName",
|
||||||
|
"cutoffDate",
|
||||||
"budgetId",
|
"budgetId",
|
||||||
"budgetName",
|
"budgetName",
|
||||||
"userId",
|
"userId",
|
||||||
@@ -41,6 +43,9 @@ const CSV_HEADERS = [
|
|||||||
"approvalType",
|
"approvalType",
|
||||||
"recurrence",
|
"recurrence",
|
||||||
"recurrenceStartAt",
|
"recurrenceStartAt",
|
||||||
|
"cutoffPhase",
|
||||||
|
"donatedAt",
|
||||||
|
"expenseId",
|
||||||
"invoiceDate",
|
"invoiceDate",
|
||||||
"proofUrl",
|
"proofUrl",
|
||||||
"storedFileName",
|
"storedFileName",
|
||||||
@@ -194,6 +199,36 @@ export async function GET() {
|
|||||||
createdAt: appSettings.createdAt.toISOString()
|
createdAt: appSettings.createdAt.toISOString()
|
||||||
} as CsvRow);
|
} 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) {
|
for (const user of users) {
|
||||||
rows.push({
|
rows.push({
|
||||||
recordType: "user",
|
recordType: "user",
|
||||||
@@ -249,6 +284,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const period of accountingPeriods) {
|
for (const period of accountingPeriods) {
|
||||||
|
const cutoff = periodCutoffById.get(period.id);
|
||||||
rows.push({
|
rows.push({
|
||||||
recordType: "period",
|
recordType: "period",
|
||||||
id: period.id,
|
id: period.id,
|
||||||
@@ -261,6 +297,8 @@ export async function GET() {
|
|||||||
periodStartsAt: period.startsAt.toISOString(),
|
periodStartsAt: period.startsAt.toISOString(),
|
||||||
periodEndsAt: period.endsAt.toISOString(),
|
periodEndsAt: period.endsAt.toISOString(),
|
||||||
periodIsCurrent: period.isCurrent ? "true" : "false",
|
periodIsCurrent: period.isCurrent ? "true" : "false",
|
||||||
|
cutoffName: cutoff?.cutoff_name ?? "Open Air",
|
||||||
|
cutoffDate: cutoff?.cutoff_date?.toISOString() ?? "",
|
||||||
budgetId: "",
|
budgetId: "",
|
||||||
budgetName: "",
|
budgetName: "",
|
||||||
userId: "",
|
userId: "",
|
||||||
@@ -442,6 +480,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||||
|
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
|
||||||
invoiceDate: "",
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
storedFileName: "",
|
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) {
|
for (const auditLog of auditLogs) {
|
||||||
rows.push({
|
rows.push({
|
||||||
recordType: "auditLog",
|
recordType: "auditLog",
|
||||||
|
|||||||
183
src/app/api/period-cutoffs/[id]/route.ts
Normal file
183
src/app/api/period-cutoffs/[id]/route.ts
Normal file
@@ -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<CutoffRow[]>`
|
||||||
|
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 });
|
||||||
|
}
|
||||||
107
src/app/api/periods/[id]/cutoffs/route.ts
Normal file
107
src/app/api/periods/[id]/cutoffs/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@ type Context = {
|
|||||||
const periodSchema = z.object({
|
const periodSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(80),
|
name: z.string().trim().min(2).max(80),
|
||||||
startsAt: z.coerce.date(),
|
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) {
|
export async function PATCH(request: Request, { params }: Context) {
|
||||||
@@ -76,6 +80,16 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
endsAt: parsed.data.endsAt
|
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, {
|
await createAuditLog(prisma, {
|
||||||
actorId: viewer.id,
|
actorId: viewer.id,
|
||||||
@@ -90,10 +104,16 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
startsAt: updatedPeriod.startsAt.toISOString(),
|
startsAt: updatedPeriod.startsAt.toISOString(),
|
||||||
endsAt: updatedPeriod.endsAt.toISOString(),
|
endsAt: updatedPeriod.endsAt.toISOString(),
|
||||||
|
cutoffName: cutoff.cutoff_name,
|
||||||
|
cutoffDate: cutoff.cutoff_date?.toISOString() ?? null,
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "period.update",
|
kind: "period.update",
|
||||||
previous: snapshotPeriod(period),
|
previous: snapshotPeriod(period),
|
||||||
next: snapshotPeriod(updatedPeriod)
|
next: snapshotPeriod({
|
||||||
|
...updatedPeriod,
|
||||||
|
cutoffName: cutoff.cutoff_name,
|
||||||
|
cutoffDate: cutoff.cutoff_date
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ export async function POST(request: Request) {
|
|||||||
isCurrent: false
|
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) {
|
if (copyBudgetsFromPeriodId) {
|
||||||
const sourceBudgets = await tx.budget.findMany({
|
const sourceBudgets = await tx.budget.findMany({
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
? parsed.data.workingGroupId
|
? parsed.data.workingGroupId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (parsed.data.role === "MEMBER" && !workingGroupId) {
|
|
||||||
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workingGroupId) {
|
if (workingGroupId) {
|
||||||
const workingGroup = await prisma.workingGroup.findUnique({
|
const workingGroup = await prisma.workingGroup.findUnique({
|
||||||
where: { id: workingGroupId }
|
where: { id: workingGroupId }
|
||||||
|
|||||||
@@ -72,10 +72,6 @@ export async function POST(request: Request) {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parsed.data.role === "MEMBER" && !workingGroupId) {
|
|
||||||
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workingGroupId) {
|
if (workingGroupId) {
|
||||||
const workingGroup = await prisma.workingGroup.findUnique({
|
const workingGroup = await prisma.workingGroup.findUnique({
|
||||||
where: { id: workingGroupId }
|
where: { id: workingGroupId }
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
name: "RFP Finanz\u00fcbersicht",
|
name: "RFP Finanz\u00fcbersicht",
|
||||||
short_name: "RFP Finanzen",
|
short_name: "RFP Finanzen",
|
||||||
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
|
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
|
||||||
|
id: "/",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#F5F1E8",
|
background_color: "#F5F1E8",
|
||||||
theme_color: "#3B5AE0",
|
theme_color: "#3B5AE0",
|
||||||
|
|||||||
105
src/app/page.tsx
105
src/app/page.tsx
@@ -7,6 +7,7 @@ import { getRollbackMetadata } from "@/lib/audit-log";
|
|||||||
import type {
|
import type {
|
||||||
DashboardAccountingPeriod,
|
DashboardAccountingPeriod,
|
||||||
DashboardAuditLog,
|
DashboardAuditLog,
|
||||||
|
DashboardDonation,
|
||||||
DashboardManagedUser,
|
DashboardManagedUser,
|
||||||
DashboardSettings,
|
DashboardSettings,
|
||||||
DashboardViewer,
|
DashboardViewer,
|
||||||
@@ -141,6 +142,66 @@ export default async function DashboardPage() {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const periodCutoffs = await prisma.$queryRaw<
|
||||||
|
{ 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_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;
|
||||||
|
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;
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
const periodCutoffsByPeriodId = new Map<string, typeof periodCutoffs>();
|
||||||
|
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<string, number>();
|
||||||
|
const donationRowsByExpenseId = new Map<string, typeof donationRows>();
|
||||||
|
for (const donation of donationRows) {
|
||||||
|
if (donation.expense_id) {
|
||||||
|
donationsByExpenseId.set(
|
||||||
|
donation.expense_id,
|
||||||
|
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
|
||||||
|
);
|
||||||
|
donationRowsByExpenseId.set(donation.expense_id, [
|
||||||
|
...(donationRowsByExpenseId.get(donation.expense_id) ?? []),
|
||||||
|
donation
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const serializedViewer: DashboardViewer = {
|
const serializedViewer: DashboardViewer = {
|
||||||
id: viewer.id,
|
id: viewer.id,
|
||||||
name: viewer.username,
|
name: viewer.username,
|
||||||
@@ -197,6 +258,21 @@ export default async function DashboardPage() {
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt,
|
recurrenceStartAt,
|
||||||
|
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,
|
||||||
|
title: donation.title,
|
||||||
|
description: donation.description,
|
||||||
|
amount: Number(donation.amount),
|
||||||
|
donatedAt: donation.donated_at.toISOString()
|
||||||
|
})),
|
||||||
|
netPeriodAmount: Math.max(
|
||||||
|
0,
|
||||||
|
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -
|
||||||
|
(donationsByExpenseId.get(expense.id) ?? 0)
|
||||||
|
),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||||
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||||
documents: expense.documents.map((document) => ({
|
documents: expense.documents.map((document) => ({
|
||||||
@@ -245,11 +321,37 @@ export default async function DashboardPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({
|
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,
|
id: period.id,
|
||||||
name: period.name,
|
name: period.name,
|
||||||
startsAt: period.startsAt.toISOString(),
|
startsAt: period.startsAt.toISOString(),
|
||||||
endsAt: period.endsAt.toISOString(),
|
endsAt: period.endsAt.toISOString(),
|
||||||
isCurrent: period.isCurrent
|
isCurrent: period.isCurrent,
|
||||||
|
cutoffName: primaryCutoffByPeriodId.get(period.id)?.name ?? "Open Air",
|
||||||
|
cutoffDate: primaryCutoffByPeriodId.get(period.id)?.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,
|
||||||
|
workingGroupId: donation.working_group_id,
|
||||||
|
workingGroupName: donation.working_group_name,
|
||||||
|
expenseTitle: donation.expense_title,
|
||||||
|
createdAt: donation.created_at.toISOString(),
|
||||||
|
creator: {
|
||||||
|
id: donation.creator_id,
|
||||||
|
name: donation.creator_name
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
||||||
@@ -282,6 +384,7 @@ export default async function DashboardPage() {
|
|||||||
accountingPeriods={serializedPeriods}
|
accountingPeriods={serializedPeriods}
|
||||||
currentPeriodId={currentPeriod.id}
|
currentPeriodId={currentPeriod.id}
|
||||||
settings={serializedSettings}
|
settings={serializedSettings}
|
||||||
|
donations={serializedDonations}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,24 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography,
|
||||||
|
useMediaQuery
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
||||||
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
|
import type {
|
||||||
|
DashboardBudget,
|
||||||
|
DashboardExpense,
|
||||||
|
DashboardExpenseDonation,
|
||||||
|
DashboardPeriodCutoff,
|
||||||
|
DashboardViewer,
|
||||||
|
DashboardWorkingGroup
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
import {
|
import {
|
||||||
approvalLabel,
|
approvalLabel,
|
||||||
canDeleteExpense,
|
canDeleteExpense,
|
||||||
@@ -42,12 +51,22 @@ import {
|
|||||||
requiresManualApproval
|
requiresManualApproval
|
||||||
} from "@/lib/domain";
|
} from "@/lib/domain";
|
||||||
|
|
||||||
|
type CutoffSelectionOption = {
|
||||||
|
value: string;
|
||||||
|
cutoffId: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BudgetColumnProps = {
|
type BudgetColumnProps = {
|
||||||
group: DashboardWorkingGroup;
|
group: DashboardWorkingGroup;
|
||||||
|
workingGroups: DashboardWorkingGroup[];
|
||||||
|
cutoffs: DashboardPeriodCutoff[];
|
||||||
viewer: DashboardViewer;
|
viewer: DashboardViewer;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
approvalThreshold: number;
|
approvalThreshold: number;
|
||||||
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
|
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
|
||||||
|
focusBudgetId?: string | null;
|
||||||
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
||||||
onMarkPaid: (expenseId: string) => Promise<void>;
|
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||||
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||||
@@ -57,6 +76,31 @@ type BudgetColumnProps = {
|
|||||||
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
||||||
onDeleteBudget: (budgetId: string) => Promise<void>;
|
onDeleteBudget: (budgetId: string) => Promise<void>;
|
||||||
onDeleteExpense: (expenseId: string) => Promise<void>;
|
onDeleteExpense: (expenseId: string) => Promise<void>;
|
||||||
|
onUpdateExpense: (
|
||||||
|
expenseId: string,
|
||||||
|
draft: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
agId: string;
|
||||||
|
budgetId: string;
|
||||||
|
cutoffId: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
|
}
|
||||||
|
) => Promise<void>;
|
||||||
|
onUpdateDonation: (
|
||||||
|
donationId: string,
|
||||||
|
draft: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
donatedAt: string;
|
||||||
|
target: "GENERAL" | "EXPENSE";
|
||||||
|
workingGroupId: string;
|
||||||
|
expenseId: string;
|
||||||
|
}
|
||||||
|
) => Promise<void>;
|
||||||
|
onDeleteDonation: (donationId: string, title: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
type BudgetDraft = {
|
type BudgetDraft = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -64,6 +108,16 @@ type BudgetDraft = {
|
|||||||
colorCode: string;
|
colorCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssignedDonationDraft = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
donatedAt: string;
|
||||||
|
target: "GENERAL" | "EXPENSE";
|
||||||
|
workingGroupId: string;
|
||||||
|
expenseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR"
|
currency: "EUR"
|
||||||
@@ -94,7 +148,51 @@ function createDraft(budget: DashboardBudget): BudgetDraft {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusChips({ expense }: { expense: DashboardExpense }) {
|
function createCutoffSelectionValue(cutoffId: string, cutoffPhase: "PRE" | "POST") {
|
||||||
|
return `${cutoffId}:${cutoffPhase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCutoffSelectionValue(value: string) {
|
||||||
|
const [cutoffId, cutoffPhase] = value.split(":");
|
||||||
|
|
||||||
|
return {
|
||||||
|
cutoffId: cutoffId ?? "",
|
||||||
|
cutoffPhase: cutoffPhase === "POST" ? "POST" : "PRE"
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCutoffSelectionOptions(cutoffs: DashboardPeriodCutoff[]): CutoffSelectionOption[] {
|
||||||
|
if (cutoffs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: CutoffSelectionOption[] = cutoffs.map((cutoff) => ({
|
||||||
|
value: createCutoffSelectionValue(cutoff.id, "PRE"),
|
||||||
|
cutoffId: cutoff.id,
|
||||||
|
cutoffPhase: "PRE" as const,
|
||||||
|
label: `Pre ${cutoff.name}`
|
||||||
|
}));
|
||||||
|
const lastCutoff = cutoffs[cutoffs.length - 1];
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
value: createCutoffSelectionValue(lastCutoff.id, "POST"),
|
||||||
|
cutoffId: lastCutoff.id,
|
||||||
|
cutoffPhase: "POST" as const,
|
||||||
|
label: `Post ${lastCutoff.name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusChips({
|
||||||
|
expense,
|
||||||
|
canEditDonations,
|
||||||
|
onEditDonation
|
||||||
|
}: {
|
||||||
|
expense: DashboardExpense;
|
||||||
|
canEditDonations: boolean;
|
||||||
|
onEditDonation: (donation: DashboardExpenseDonation) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
<Chip
|
<Chip
|
||||||
@@ -110,6 +208,24 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
|||||||
{expense.documentedAt ? (
|
{expense.documentedAt ? (
|
||||||
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{expense.donations.map((donation) => (
|
||||||
|
<Chip
|
||||||
|
key={donation.id}
|
||||||
|
label={`Spende: ${formatCurrency(donation.amount)}`}
|
||||||
|
size="small"
|
||||||
|
onDelete={canEditDonations ? () => onEditDonation(donation) : undefined}
|
||||||
|
deleteIcon={canEditDonations ? <EditRoundedIcon /> : undefined}
|
||||||
|
sx={{
|
||||||
|
...wrappingChipSx,
|
||||||
|
bgcolor: "#F6C343",
|
||||||
|
color: "#1F1600",
|
||||||
|
fontWeight: 700,
|
||||||
|
"& .MuiChip-deleteIcon": {
|
||||||
|
color: "#1F1600"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{expense.recurrence === "MONTHLY" ? (
|
{expense.recurrence === "MONTHLY" ? (
|
||||||
<Chip
|
<Chip
|
||||||
label={recurrenceLabel(expense.recurrence)}
|
label={recurrenceLabel(expense.recurrence)}
|
||||||
@@ -124,23 +240,26 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getApprovedSpend(expenses: 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[]) {
|
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[]) {
|
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({
|
export function BudgetColumn({
|
||||||
group,
|
group,
|
||||||
|
workingGroups,
|
||||||
|
cutoffs,
|
||||||
viewer,
|
viewer,
|
||||||
busy,
|
busy,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
requiredApprovalTypes,
|
requiredApprovalTypes,
|
||||||
|
focusBudgetId,
|
||||||
onApprove,
|
onApprove,
|
||||||
onMarkPaid,
|
onMarkPaid,
|
||||||
onDocument,
|
onDocument,
|
||||||
@@ -149,23 +268,48 @@ export function BudgetColumn({
|
|||||||
onDeleteWorkingGroup,
|
onDeleteWorkingGroup,
|
||||||
onSaveBudget,
|
onSaveBudget,
|
||||||
onDeleteBudget,
|
onDeleteBudget,
|
||||||
onDeleteExpense
|
onDeleteExpense,
|
||||||
|
onUpdateExpense,
|
||||||
|
onUpdateDonation,
|
||||||
|
onDeleteDonation
|
||||||
}: BudgetColumnProps) {
|
}: BudgetColumnProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg"));
|
||||||
const [budgetDrafts, setBudgetDrafts] = useState<Record<string, BudgetDraft>>({});
|
const [budgetDrafts, setBudgetDrafts] = useState<Record<string, BudgetDraft>>({});
|
||||||
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
|
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
|
||||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
||||||
|
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
|
||||||
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
||||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedExpenseDetails, setExpandedExpenseDetails] = useState<Record<string, boolean>>({});
|
||||||
|
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
|
||||||
|
const [editingAssignedDonationId, setEditingAssignedDonationId] = useState<string | null>(null);
|
||||||
|
const [expenseDrafts, setExpenseDrafts] = useState<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
agId: string;
|
||||||
|
budgetId: string;
|
||||||
|
cutoffId: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>({});
|
||||||
|
const [assignedDonationDrafts, setAssignedDonationDrafts] = useState<Record<string, AssignedDonationDraft>>({});
|
||||||
|
|
||||||
const budgetCardWidth = 352;
|
const budgetCardWidth = 286;
|
||||||
const desktopBudgetGap = 16;
|
const desktopBudgetGap = 12;
|
||||||
const desktopBudgetListWidth =
|
const desktopBudgetListWidth =
|
||||||
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
||||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 64, 456);
|
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
|
||||||
const canEditBudgets = canManageBudgets(viewer.role);
|
const canEditBudgets = canManageBudgets(viewer.role);
|
||||||
|
const canEditExpenses = canManageBudgets(viewer.role);
|
||||||
|
const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBudgetDrafts(
|
setBudgetDrafts(
|
||||||
@@ -183,6 +327,18 @@ export function BudgetColumn({
|
|||||||
setGroupDraftName(group.name);
|
setGroupDraftName(group.name);
|
||||||
}, [group.name]);
|
}, [group.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group.budgets.some((budget) => budget.id === selectedBudgetId)) {
|
||||||
|
setSelectedBudgetId(group.budgets[0]?.id ?? "");
|
||||||
|
}
|
||||||
|
}, [group.budgets, selectedBudgetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusBudgetId && group.budgets.some((budget) => budget.id === focusBudgetId)) {
|
||||||
|
setSelectedBudgetId(focusBudgetId);
|
||||||
|
}
|
||||||
|
}, [focusBudgetId, group.budgets]);
|
||||||
|
|
||||||
const approvedSpend = useMemo(
|
const approvedSpend = useMemo(
|
||||||
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
|
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
|
||||||
[group.budgets]
|
[group.budgets]
|
||||||
@@ -216,6 +372,54 @@ 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,
|
||||||
|
cutoffId: expense.cutoffId ?? cutoffs[0]?.id ?? "",
|
||||||
|
cutoffPhase: expense.cutoffPhase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpenseDraft(expense: DashboardExpense, patch: Partial<ReturnType<typeof getExpenseDraft>>) {
|
||||||
|
setExpenseDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: {
|
||||||
|
...getExpenseDraft(expense),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssignedDonationDraft(expense: DashboardExpense, donation: DashboardExpenseDonation): AssignedDonationDraft {
|
||||||
|
return assignedDonationDrafts[donation.id] ?? {
|
||||||
|
title: donation.title,
|
||||||
|
description: donation.description ?? "",
|
||||||
|
amount: donation.amount.toFixed(2),
|
||||||
|
donatedAt: donation.donatedAt.slice(0, 10),
|
||||||
|
target: "EXPENSE",
|
||||||
|
workingGroupId: group.id,
|
||||||
|
expenseId: expense.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssignedDonationDraft(
|
||||||
|
expense: DashboardExpense,
|
||||||
|
donation: DashboardExpenseDonation,
|
||||||
|
patch: Partial<AssignedDonationDraft>
|
||||||
|
) {
|
||||||
|
setAssignedDonationDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[donation.id]: {
|
||||||
|
...getAssignedDonationDraft(expense, donation),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function resetDraft(budget: DashboardBudget) {
|
function resetDraft(budget: DashboardBudget) {
|
||||||
setBudgetDrafts((current) => ({
|
setBudgetDrafts((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -251,6 +455,34 @@ export function BudgetColumn({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedBudget = group.budgets.find((budget) => budget.id === selectedBudgetId) ?? group.budgets[0] ?? null;
|
||||||
|
const visibleBudgets = isCompactLayout && selectedBudget ? [selectedBudget] : group.budgets;
|
||||||
|
const mobilePrimarySelectSx = {
|
||||||
|
width: "100%",
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
minHeight: 64,
|
||||||
|
borderRadius: "28px",
|
||||||
|
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.72 : 0.96),
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, isDark ? 0.54 : 0.38)
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.72)
|
||||||
|
},
|
||||||
|
"&.Mui-focused fieldset": {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.palette.primary.main
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"& .MuiInputLabel-root": {
|
||||||
|
color: theme.palette.text.secondary
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
fontSize: "1.08rem",
|
||||||
|
fontWeight: 600
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
@@ -413,6 +645,23 @@ export function BudgetColumn({
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isCompactLayout && group.budgets.length > 1 ? (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Budget auswählen"
|
||||||
|
value={selectedBudget?.id ?? ""}
|
||||||
|
onChange={(event) => setSelectedBudgetId(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={mobilePrimarySelectSx}
|
||||||
|
>
|
||||||
|
{group.budgets.map((budget) => (
|
||||||
|
<MenuItem key={budget.id} value={budget.id}>
|
||||||
|
{budget.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -420,11 +669,11 @@ export function BudgetColumn({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
pb: 0,
|
pb: 0,
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
width: { md: desktopBudgetListWidth },
|
width: isCompactLayout ? "100%" : desktopBudgetListWidth,
|
||||||
minWidth: { md: desktopBudgetListWidth }
|
minWidth: isCompactLayout ? 0 : desktopBudgetListWidth
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.budgets.map((budget) => {
|
{visibleBudgets.map((budget) => {
|
||||||
const draft = getDraft(budget);
|
const draft = getDraft(budget);
|
||||||
const isEditing = editingBudgetId === budget.id;
|
const isEditing = editingBudgetId === budget.id;
|
||||||
const budgetApproved = getApprovedSpend(budget.expenses);
|
const budgetApproved = getApprovedSpend(budget.expenses);
|
||||||
@@ -444,9 +693,9 @@ export function BudgetColumn({
|
|||||||
<Box
|
<Box
|
||||||
key={budget.id}
|
key={budget.id}
|
||||||
sx={{
|
sx={{
|
||||||
minWidth: { xs: 280, sm: 312, md: budgetCardWidth },
|
minWidth: isCompactLayout ? 0 : budgetCardWidth,
|
||||||
width: { xs: "84vw", sm: 312, md: budgetCardWidth },
|
width: isCompactLayout ? "100%" : budgetCardWidth,
|
||||||
maxWidth: { xs: 360, md: budgetCardWidth },
|
maxWidth: isCompactLayout ? "100%" : budgetCardWidth,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
scrollSnapAlign: "start"
|
scrollSnapAlign: "start"
|
||||||
}}
|
}}
|
||||||
@@ -675,14 +924,15 @@ export function BudgetColumn({
|
|||||||
: [];
|
: [];
|
||||||
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
||||||
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
||||||
|
const isDetailsExpanded = expandedExpenseDetails[expense.id] ?? false;
|
||||||
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2.25,
|
p: 1.55,
|
||||||
borderRadius: "18px",
|
borderRadius: "14px",
|
||||||
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
expense.approvalStatus === "APPROVED"
|
expense.approvalStatus === "APPROVED"
|
||||||
@@ -691,8 +941,8 @@ export function BudgetColumn({
|
|||||||
touchAction: "pan-x pan-y"
|
touchAction: "pan-x pan-y"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.4}>
|
<Stack spacing={1}>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={0.75}>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
@@ -707,10 +957,292 @@ export function BudgetColumn({
|
|||||||
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · 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.amount)} von ${expense.creator.name}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{expense.donationAmount > 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{`Rest: ${formatCurrency(expense.netPeriodAmount)}`}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<StatusChips expense={expense} />
|
<StatusChips
|
||||||
|
expense={expense}
|
||||||
|
canEditDonations={canEditDonations}
|
||||||
|
onEditDonation={(donation) => {
|
||||||
|
setEditingAssignedDonationId(donation.id);
|
||||||
|
setAssignedDonationDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[donation.id]: getAssignedDonationDraft(expense, donation)
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{expense.donations.map((donation) => {
|
||||||
|
if (editingAssignedDonationId !== donation.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const donationDraft = getAssignedDonationDraft(expense, donation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={donation.id}
|
||||||
|
sx={{
|
||||||
|
p: 1.2,
|
||||||
|
borderRadius: "14px",
|
||||||
|
bgcolor: alpha("#F6C343", 0.16),
|
||||||
|
border: `1px solid ${alpha("#F6C343", 0.36)}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
Spende bearbeiten
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
size="small"
|
||||||
|
value={donationDraft.title}
|
||||||
|
onChange={(event) => updateAssignedDonationDraft(expense, donation, { title: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Betrag in EUR"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
value={donationDraft.amount}
|
||||||
|
onChange={(event) => updateAssignedDonationDraft(expense, donation, { amount: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Spendendatum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={donationDraft.donatedAt}
|
||||||
|
onChange={(event) => updateAssignedDonationDraft(expense, donation, { donatedAt: event.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
size="small"
|
||||||
|
value={donationDraft.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAssignedDonationDraft(expense, donation, { description: event.target.value })
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
await onUpdateDonation(donation.id, donationDraft);
|
||||||
|
setEditingAssignedDonationId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDeleteDonation(donation.id, donation.title);
|
||||||
|
setEditingAssignedDonationId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditingAssignedDonationId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedExpenseDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: !isDetailsExpanded
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
endIcon={isDetailsExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
|
||||||
|
sx={{ alignSelf: "flex-start", px: 0 }}
|
||||||
|
>
|
||||||
|
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canEditExpenses ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditRoundedIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingExpenseId(expense.id);
|
||||||
|
setExpenseDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: getExpenseDraft(expense)
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editingExpenseId === expense.id ? (
|
||||||
|
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
|
||||||
|
{(() => {
|
||||||
|
const draft = getExpenseDraft(expense);
|
||||||
|
const editGroup =
|
||||||
|
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||||
|
const editBudgets = editGroup.budgets;
|
||||||
|
const cutoffOptions = getCutoffSelectionOptions(cutoffs);
|
||||||
|
const selectedCutoffValue = createCutoffSelectionValue(draft.cutoffId, draft.cutoffPhase);
|
||||||
|
const selectedCutoffOption =
|
||||||
|
cutoffOptions.find((option) => option.value === selectedCutoffValue) ??
|
||||||
|
cutoffOptions[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
size="small"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { title: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
size="small"
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { description: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Betrag in EUR"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
value={draft.amount}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { amount: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="AG"
|
||||||
|
size="small"
|
||||||
|
value={draft.agId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextGroup = workingGroups.find((entry) => entry.id === event.target.value);
|
||||||
|
updateExpenseDraft(expense, {
|
||||||
|
agId: event.target.value,
|
||||||
|
budgetId: nextGroup?.budgets[0]?.id ?? ""
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{workingGroups.map((entry) => (
|
||||||
|
<MenuItem key={entry.id} value={entry.id}>
|
||||||
|
{entry.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Budget"
|
||||||
|
size="small"
|
||||||
|
value={draft.budgetId}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { budgetId: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
disabled={editBudgets.length === 0}
|
||||||
|
>
|
||||||
|
{editBudgets.map((entry) => (
|
||||||
|
<MenuItem key={entry.id} value={entry.id}>
|
||||||
|
{entry.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Stichtag-Zuordnung"
|
||||||
|
size="small"
|
||||||
|
value={selectedCutoffOption?.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = parseCutoffSelectionValue(event.target.value);
|
||||||
|
updateExpenseDraft(expense, next);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
disabled={cutoffOptions.length === 0}
|
||||||
|
>
|
||||||
|
{cutoffOptions.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !draft.budgetId}
|
||||||
|
onClick={async () => {
|
||||||
|
await onUpdateExpense(expense.id, {
|
||||||
|
...draft,
|
||||||
|
cutoffId: selectedCutoffOption?.cutoffId ?? draft.cutoffId,
|
||||||
|
cutoffPhase: selectedCutoffOption?.cutoffPhase ?? draft.cutoffPhase
|
||||||
|
});
|
||||||
|
setEditingExpenseId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditingExpenseId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={isDetailsExpanded} unmountOnExit>
|
||||||
|
<Stack spacing={1}>
|
||||||
{expense.description ? (
|
{expense.description ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
{expense.description}
|
{expense.description}
|
||||||
@@ -1007,6 +1539,8 @@ export function BudgetColumn({
|
|||||||
new Date(expense.createdAt)
|
new Date(expense.createdAt)
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,13 @@ export function ServiceWorkerRegistration() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
navigator.serviceWorker
|
||||||
// Registrierung darf die App nicht blockieren.
|
.register("/sw.js")
|
||||||
});
|
.then((registration) => registration.update().catch(() => null))
|
||||||
|
.catch(() => {
|
||||||
|
// Registrierung darf die App nicht blockieren.
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ export function snapshotWorkingGroup(workingGroup: Pick<WorkingGroup, "id" | "na
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "startsAt" | "endsAt" | "isCurrent" | "createdAt">) {
|
export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "startsAt" | "endsAt" | "isCurrent" | "createdAt"> & {
|
||||||
|
cutoffName?: string;
|
||||||
|
cutoffDate?: Date | null;
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
id: period.id,
|
id: period.id,
|
||||||
name: period.name,
|
name: period.name,
|
||||||
startsAt: period.startsAt.toISOString(),
|
startsAt: period.startsAt.toISOString(),
|
||||||
endsAt: period.endsAt.toISOString(),
|
endsAt: period.endsAt.toISOString(),
|
||||||
isCurrent: period.isCurrent,
|
isCurrent: period.isCurrent,
|
||||||
|
cutoffName: period.cutoffName,
|
||||||
|
cutoffDate: period.cutoffDate?.toISOString() ?? null,
|
||||||
createdAt: period.createdAt.toISOString()
|
createdAt: period.createdAt.toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,9 @@ export function snapshotExpense(
|
|||||||
| "createdAt"
|
| "createdAt"
|
||||||
| "paidAt"
|
| "paidAt"
|
||||||
| "documentedAt"
|
| "documentedAt"
|
||||||
>
|
> & {
|
||||||
|
cutoffPhase?: "PRE" | "POST";
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: expense.id,
|
id: expense.id,
|
||||||
@@ -77,12 +84,39 @@ export function snapshotExpense(
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
|
cutoffPhase: expense.cutoffPhase ?? "PRE",
|
||||||
createdAt: expense.createdAt.toISOString(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||||
documentedAt: expense.documentedAt?.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<Approval, "id" | "expenseId" | "userId" | "approvalType" | "timestamp">) {
|
export function snapshotApproval(approval: Pick<Approval, "id" | "expenseId" | "userId" | "approvalType" | "timestamp">) {
|
||||||
return {
|
return {
|
||||||
id: approval.id,
|
id: approval.id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ApprovalStatusValue,
|
ApprovalStatusValue,
|
||||||
ApprovalTypeValue,
|
ApprovalTypeValue,
|
||||||
BudgetReleaseNotifyTargetValue,
|
BudgetReleaseNotifyTargetValue,
|
||||||
|
CutoffPhaseValue,
|
||||||
ExpenseRecurrenceValue
|
ExpenseRecurrenceValue
|
||||||
} from "@/lib/domain";
|
} from "@/lib/domain";
|
||||||
|
|
||||||
@@ -12,6 +13,16 @@ export type DashboardAccountingPeriod = {
|
|||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
cutoffName: string;
|
||||||
|
cutoffDate: string | null;
|
||||||
|
cutoffs: DashboardPeriodCutoff[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPeriodCutoff = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
date: string | null;
|
||||||
|
periodId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardViewer = {
|
export type DashboardViewer = {
|
||||||
@@ -55,6 +66,14 @@ export type DashboardExpenseDocument = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardExpenseDonation = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: number;
|
||||||
|
donatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardExpense = {
|
export type DashboardExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -68,6 +87,11 @@ export type DashboardExpense = {
|
|||||||
approvalStatus: ApprovalStatusValue;
|
approvalStatus: ApprovalStatusValue;
|
||||||
recurrence: ExpenseRecurrenceValue;
|
recurrence: ExpenseRecurrenceValue;
|
||||||
recurrenceStartAt: string | null;
|
recurrenceStartAt: string | null;
|
||||||
|
cutoffId: string | null;
|
||||||
|
cutoffPhase: CutoffPhaseValue;
|
||||||
|
donationAmount: number;
|
||||||
|
donations: DashboardExpenseDonation[];
|
||||||
|
netPeriodAmount: number;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
documentedAt: string | null;
|
documentedAt: string | null;
|
||||||
documents: DashboardExpenseDocument[];
|
documents: DashboardExpenseDocument[];
|
||||||
@@ -79,6 +103,24 @@ export type DashboardExpense = {
|
|||||||
approvals: DashboardApproval[];
|
approvals: DashboardApproval[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardDonation = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: number;
|
||||||
|
donatedAt: string;
|
||||||
|
periodId: string;
|
||||||
|
expenseId: string | null;
|
||||||
|
workingGroupId: string | null;
|
||||||
|
workingGroupName: string | null;
|
||||||
|
expenseTitle: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
creator: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardSettings = {
|
export type DashboardSettings = {
|
||||||
approvalThreshold: number;
|
approvalThreshold: number;
|
||||||
requiredApprovalTypes: ApprovalTypeValue[];
|
requiredApprovalTypes: ApprovalTypeValue[];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
|||||||
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
||||||
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
||||||
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
||||||
|
export type CutoffPhaseValue = "PRE" | "POST";
|
||||||
export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
|
export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
|
||||||
|
|
||||||
export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
|
export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
|
||||||
@@ -33,7 +34,7 @@ export function roleLabel(role: AppRole) {
|
|||||||
case "FINANCE":
|
case "FINANCE":
|
||||||
return "AG Finanzen";
|
return "AG Finanzen";
|
||||||
case "MEMBER":
|
case "MEMBER":
|
||||||
return "AG-Mitglied";
|
return "Mitglied";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
export function hasAdministrativeAccess(role: AppRole) {
|
||||||
return role === "BOARD" || role === "ORGA" || role === "FINANCE";
|
return role === "BOARD" || role === "ORGA" || role === "FINANCE";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ export async function uploadExpenseProofToDrive(input: {
|
|||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
body: Readable.from(input.buffer)
|
body: Readable.from(input.buffer)
|
||||||
},
|
},
|
||||||
fields: "id, webViewLink"
|
fields: "id, name, webViewLink, webContentLink",
|
||||||
|
supportsAllDrives: true
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte den Rechnungsbeleg nicht hochladen.", [
|
throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte den Rechnungsbeleg nicht hochladen.", [
|
||||||
`Zielordner: ${config.folderId}`,
|
`Zielordner: ${config.folderId}`,
|
||||||
@@ -243,7 +244,8 @@ export async function uploadExpenseProofToDrive(input: {
|
|||||||
requestBody: {
|
requestBody: {
|
||||||
type: "anyone",
|
type: "anyone",
|
||||||
role: "reader"
|
role: "reader"
|
||||||
}
|
},
|
||||||
|
supportsAllDrives: true
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
throw mapDriveError(error, "DRIVE_PERMISSION_FAILED", "Google Drive konnte den Freigabe-Link nicht erstellen.", [
|
throw mapDriveError(error, "DRIVE_PERMISSION_FAILED", "Google Drive konnte den Freigabe-Link nicht erstellen.", [
|
||||||
`Die Datei wurde vermutlich bereits erstellt. Drive-Datei-ID: ${response.data.id}`
|
`Die Datei wurde vermutlich bereits erstellt. Drive-Datei-ID: ${response.data.id}`
|
||||||
@@ -273,7 +275,8 @@ export async function runDriveDiagnostics() {
|
|||||||
mimeType: "text/plain",
|
mimeType: "text/plain",
|
||||||
body: Readable.from(Buffer.from("RFP Finanzen Drive API Test\n", "utf8"))
|
body: Readable.from(Buffer.from("RFP Finanzen Drive API Test\n", "utf8"))
|
||||||
},
|
},
|
||||||
fields: "id, webViewLink"
|
fields: "id, name, webViewLink, webContentLink",
|
||||||
|
supportsAllDrives: true
|
||||||
});
|
});
|
||||||
|
|
||||||
createdFileId = response.data.id ?? null;
|
createdFileId = response.data.id ?? null;
|
||||||
@@ -282,7 +285,7 @@ export async function runDriveDiagnostics() {
|
|||||||
throw new DriveIntegrationError("Google Drive hat für die Testdatei keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING");
|
throw new DriveIntegrationError("Google Drive hat für die Testdatei keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING");
|
||||||
}
|
}
|
||||||
|
|
||||||
await drive.files.delete({ fileId: createdFileId });
|
await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -296,7 +299,7 @@ export async function runDriveDiagnostics() {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (createdFileId) {
|
if (createdFileId) {
|
||||||
await drive.files.delete({ fileId: createdFileId }).catch((cleanupError: unknown) => {
|
await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true }).catch((cleanupError: unknown) => {
|
||||||
throw new DriveIntegrationError(
|
throw new DriveIntegrationError(
|
||||||
"Drive-Test ist fehlgeschlagen und die temporäre Testdatei konnte nicht gelöscht werden.",
|
"Drive-Test ist fehlgeschlagen und die temporäre Testdatei konnte nicht gelöscht werden.",
|
||||||
"DRIVE_DIAGNOSTIC_CLEANUP_FAILED",
|
"DRIVE_DIAGNOSTIC_CLEANUP_FAILED",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
@@ -26,4 +25,3 @@
|
|||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user