This commit is contained in:
@@ -21,6 +21,7 @@ const updateExpenseSchema = z.object({
|
||||
amount: z.coerce.number().positive(),
|
||||
agId: z.string().trim().min(1),
|
||||
budgetId: z.string().trim().min(1),
|
||||
cutoffId: z.string().trim().min(1).nullable().optional(),
|
||||
cutoffPhase: z.enum(["PRE", "POST"])
|
||||
});
|
||||
|
||||
@@ -59,6 +60,24 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
|
||||
}
|
||||
|
||||
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM period_cutoffs
|
||||
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId}
|
||||
`;
|
||||
const fallbackCutoffRows = parsed.data.cutoffId
|
||||
? []
|
||||
: await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM period_cutoffs
|
||||
WHERE period_id = ${expense.periodId}
|
||||
ORDER BY date ASC NULLS LAST, created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
|
||||
|
||||
if (parsed.data.cutoffId && !cutoffId) {
|
||||
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
|
||||
}
|
||||
|
||||
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
|
||||
SELECT cutoff_phase FROM expenses WHERE id = ${id}
|
||||
`;
|
||||
@@ -77,7 +96,11 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
budgetId: parsed.data.budgetId
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE expenses
|
||||
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
await createAuditLog(prisma, {
|
||||
actorId: viewer.id,
|
||||
|
||||
@@ -31,6 +31,7 @@ const expenseSchema = z
|
||||
amount: z.coerce.number().positive(),
|
||||
agId: z.string().trim().min(1),
|
||||
budgetId: z.string().trim().min(1),
|
||||
cutoffId: z.string().trim().min(1).optional(),
|
||||
cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"),
|
||||
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
|
||||
recurrenceStartAt: z
|
||||
@@ -93,6 +94,24 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG." }, { status: 404 });
|
||||
}
|
||||
|
||||
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM period_cutoffs
|
||||
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${budget.periodId}
|
||||
`;
|
||||
const fallbackCutoffRows = parsed.data.cutoffId
|
||||
? []
|
||||
: await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM period_cutoffs
|
||||
WHERE period_id = ${budget.periodId}
|
||||
ORDER BY date ASC NULLS LAST, created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
|
||||
|
||||
if (parsed.data.cutoffId && !cutoffId) {
|
||||
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
|
||||
}
|
||||
|
||||
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
||||
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
|
||||
const recurrenceStartAt =
|
||||
@@ -115,7 +134,11 @@ export async function POST(request: Request) {
|
||||
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
||||
}
|
||||
});
|
||||
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${expense.id}`;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE expenses
|
||||
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
|
||||
WHERE id = ${expense.id}
|
||||
`;
|
||||
|
||||
if (needsManualApproval) {
|
||||
await notifyApprovalRequest(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -50,6 +51,13 @@ export async function POST(request: Request) {
|
||||
isCurrent: false
|
||||
}
|
||||
});
|
||||
const defaultCutoffId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO period_cutoffs (id, name, date, period_id, created_at, updated_at)
|
||||
VALUES (${defaultCutoffId}, ${"Open Air"}, ${null}, ${createdPeriod.id}, ${now}, ${now})
|
||||
`;
|
||||
|
||||
if (copyBudgetsFromPeriodId) {
|
||||
const sourceBudgets = await tx.budget.findMany({
|
||||
|
||||
+26
-9
@@ -143,11 +143,15 @@ export default async function DashboardPage() {
|
||||
: [];
|
||||
|
||||
const periodCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
|
||||
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
|
||||
{ id: string; name: string; date: Date | null; period_id: string }[]
|
||||
>`
|
||||
SELECT id, name, date, period_id
|
||||
FROM period_cutoffs
|
||||
ORDER BY date ASC NULLS LAST, created_at ASC
|
||||
`;
|
||||
const expenseCutoffs = await prisma.$queryRaw<
|
||||
{ id: string; cutoff_phase: "PRE" | "POST" }[]
|
||||
>`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
|
||||
{ id: string; cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[]
|
||||
>`SELECT id, cutoff_id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
|
||||
const donationRows = await prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
@@ -175,8 +179,14 @@ export default async function DashboardPage() {
|
||||
WHERE d.period_id = ${currentPeriod.id}
|
||||
ORDER BY d.donated_at DESC
|
||||
`;
|
||||
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
|
||||
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
|
||||
const periodCutoffsByPeriodId = new Map<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) {
|
||||
@@ -248,7 +258,8 @@ export default async function DashboardPage() {
|
||||
approvalStatus: expense.approvalStatus,
|
||||
recurrence: expense.recurrence,
|
||||
recurrenceStartAt,
|
||||
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
|
||||
cutoffId: expenseCutoffById.get(expense.id)?.cutoff_id ?? primaryCutoffByPeriodId.get(expense.periodId)?.id ?? null,
|
||||
cutoffPhase: expenseCutoffById.get(expense.id)?.cutoff_phase ?? "PRE",
|
||||
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
|
||||
donations: (donationRowsByExpenseId.get(expense.id) ?? []).map((donation) => ({
|
||||
id: donation.id,
|
||||
@@ -310,13 +321,19 @@ export default async function DashboardPage() {
|
||||
}));
|
||||
|
||||
const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({
|
||||
cutoffs: (periodCutoffsByPeriodId.get(period.id) ?? []).map((cutoff) => ({
|
||||
id: cutoff.id,
|
||||
name: cutoff.name,
|
||||
date: cutoff.date?.toISOString() ?? null,
|
||||
periodId: cutoff.period_id
|
||||
})),
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
startsAt: period.startsAt.toISOString(),
|
||||
endsAt: period.endsAt.toISOString(),
|
||||
isCurrent: period.isCurrent,
|
||||
cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air",
|
||||
cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null
|
||||
cutoffName: primaryCutoffByPeriodId.get(period.id)?.name ?? "Open Air",
|
||||
cutoffDate: primaryCutoffByPeriodId.get(period.id)?.date?.toISOString() ?? null
|
||||
}));
|
||||
|
||||
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({
|
||||
|
||||
Reference in New Issue
Block a user