Mehrere Stichtage pro Zeitraum verwalten
All checks were successful
CI / Build and Deploy (push) Successful in 2m43s
All checks were successful
CI / Build and Deploy (push) Successful in 2m43s
This commit is contained in:
@@ -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;
|
||||
@@ -86,6 +86,7 @@ model AccountingPeriod {
|
||||
isCurrent Boolean @default(false) @map("is_current")
|
||||
cutoffName String @default("Open Air") @map("cutoff_name")
|
||||
cutoffDate DateTime? @map("cutoff_date")
|
||||
cutoffs PeriodCutoff[]
|
||||
budgets Budget[]
|
||||
expenses Expense[]
|
||||
donations Donation[]
|
||||
@@ -95,6 +96,20 @@ model AccountingPeriod {
|
||||
@@map("accounting_periods")
|
||||
}
|
||||
|
||||
model PeriodCutoff {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
date DateTime? @map("date")
|
||||
periodId String @map("period_id")
|
||||
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([periodId])
|
||||
@@map("period_cutoffs")
|
||||
}
|
||||
|
||||
model AppSettings {
|
||||
id String @id @default("global")
|
||||
approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold")
|
||||
@@ -148,6 +163,7 @@ model Expense {
|
||||
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
|
||||
recurrence ExpenseRecurrence @default(NONE)
|
||||
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
||||
cutoffId String? @map("cutoff_id")
|
||||
cutoffPhase CutoffPhase @default(PRE) @map("cutoff_phase")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
documentedAt DateTime? @map("documented_at")
|
||||
@@ -157,6 +173,7 @@ model Expense {
|
||||
workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade)
|
||||
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict)
|
||||
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
||||
cutoff PeriodCutoff? @relation(fields: [cutoffId], references: [id], onDelete: SetNull)
|
||||
approvals Approval[]
|
||||
documents ExpenseDocument[]
|
||||
donations Donation[]
|
||||
|
||||
@@ -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(
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
DashboardBudget,
|
||||
DashboardExpense,
|
||||
DashboardExpenseDonation,
|
||||
DashboardPeriodCutoff,
|
||||
DashboardViewer,
|
||||
DashboardWorkingGroup
|
||||
} from "@/lib/dashboard-types";
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
type BudgetColumnProps = {
|
||||
group: DashboardWorkingGroup;
|
||||
workingGroups: DashboardWorkingGroup[];
|
||||
cutoffs: DashboardPeriodCutoff[];
|
||||
viewer: DashboardViewer;
|
||||
busy: boolean;
|
||||
approvalThreshold: number;
|
||||
@@ -75,6 +77,7 @@ type BudgetColumnProps = {
|
||||
amount: string;
|
||||
agId: string;
|
||||
budgetId: string;
|
||||
cutoffId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
}
|
||||
) => Promise<void>;
|
||||
@@ -208,6 +211,7 @@ function getPaidSpend(expenses: DashboardExpense[]) {
|
||||
export function BudgetColumn({
|
||||
group,
|
||||
workingGroups,
|
||||
cutoffs,
|
||||
viewer,
|
||||
busy,
|
||||
approvalThreshold,
|
||||
@@ -240,7 +244,18 @@ export function BudgetColumn({
|
||||
const [editingExpenseId, setEditingExpenseId] = useState<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; cutoffPhase: "PRE" | "POST" }>
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
agId: string;
|
||||
budgetId: string;
|
||||
cutoffId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
}
|
||||
>
|
||||
>({});
|
||||
const [assignedDonationDrafts, setAssignedDonationDrafts] = useState<Record<string, AssignedDonationDraft>>({});
|
||||
|
||||
@@ -321,6 +336,7 @@ export function BudgetColumn({
|
||||
amount: expense.amount.toFixed(2),
|
||||
agId: group.id,
|
||||
budgetId: expense.budgetId,
|
||||
cutoffId: expense.cutoffId ?? cutoffs[0]?.id ?? "",
|
||||
cutoffPhase: expense.cutoffPhase
|
||||
};
|
||||
}
|
||||
@@ -1062,6 +1078,8 @@ export function BudgetColumn({
|
||||
const editGroup =
|
||||
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||
const editBudgets = editGroup.budgets;
|
||||
const selectedCutoff =
|
||||
cutoffs.find((cutoff) => cutoff.id === draft.cutoffId) ?? cutoffs[0] ?? null;
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
@@ -1125,21 +1143,38 @@ export function BudgetColumn({
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
size="small"
|
||||
value={draft.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
updateExpenseDraft(expense, {
|
||||
cutoffPhase: event.target.value as "PRE" | "POST"
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">Pre Open Air</MenuItem>
|
||||
<MenuItem value="POST">Post Open Air</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
size="small"
|
||||
value={draft.cutoffId}
|
||||
onChange={(event) => updateExpenseDraft(expense, { cutoffId: event.target.value })}
|
||||
fullWidth
|
||||
disabled={cutoffs.length === 0}
|
||||
>
|
||||
{cutoffs.map((cutoff) => (
|
||||
<MenuItem key={cutoff.id} value={cutoff.id}>
|
||||
{cutoff.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Zuordnung"
|
||||
size="small"
|
||||
value={draft.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
updateExpenseDraft(expense, {
|
||||
cutoffPhase: event.target.value as "PRE" | "POST"
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">{`Pre ${selectedCutoff?.name ?? "Open Air"}`}</MenuItem>
|
||||
<MenuItem value="POST">{`Post ${selectedCutoff?.name ?? "Open Air"}`}</MenuItem>
|
||||
</TextField>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -78,6 +78,7 @@ type ExpenseFormState = {
|
||||
budgetId: string;
|
||||
recurrence: "NONE" | "MONTHLY";
|
||||
recurrenceStartAt: string;
|
||||
cutoffId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
};
|
||||
|
||||
@@ -112,9 +113,17 @@ type ExpenseEditDraft = {
|
||||
amount: string;
|
||||
agId: string;
|
||||
budgetId: string;
|
||||
cutoffId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
};
|
||||
|
||||
type CutoffFormState = {
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
type CutoffDraft = CutoffFormState;
|
||||
|
||||
type WorkingGroupFormState = {
|
||||
name: string;
|
||||
};
|
||||
@@ -224,6 +233,10 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
timeStyle: "short"
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium"
|
||||
});
|
||||
|
||||
function toDateInputValue(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
@@ -391,6 +404,7 @@ export function DashboardShell({
|
||||
budgetId: defaultBudget?.id ?? "",
|
||||
recurrence: "NONE",
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||
cutoffId: currentPeriod?.cutoffs[0]?.id ?? "",
|
||||
cutoffPhase: "PRE"
|
||||
});
|
||||
const [donationForm, setDonationForm] = useState<DonationFormState>({
|
||||
@@ -450,6 +464,12 @@ export function DashboardShell({
|
||||
});
|
||||
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
||||
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
||||
const [cutoffForm, setCutoffForm] = useState<CutoffFormState>({
|
||||
name: "Open Air",
|
||||
date: ""
|
||||
});
|
||||
const [cutoffDrafts, setCutoffDrafts] = useState<Record<string, CutoffDraft>>({});
|
||||
const [editingCutoffId, setEditingCutoffId] = useState<string | null>(null);
|
||||
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
||||
const handledDeepLinkRef = useRef<string | null>(null);
|
||||
const busyRef = useRef(busy);
|
||||
@@ -749,10 +769,19 @@ export function DashboardShell({
|
||||
setEditingUserId(null);
|
||||
}
|
||||
}, [editingUserId, managedUsersState]);
|
||||
const selectedExpenseGroup =
|
||||
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
||||
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
||||
const selectedBudgetWorkingGroup =
|
||||
useEffect(() => {
|
||||
const cutoffs = currentPeriod?.cutoffs ?? [];
|
||||
if (cutoffs.length > 0 && !cutoffs.some((cutoff) => cutoff.id === expenseForm.cutoffId)) {
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffId: cutoffs[0]?.id ?? ""
|
||||
}));
|
||||
}
|
||||
}, [currentPeriod, expenseForm.cutoffId]);
|
||||
const selectedExpenseGroup =
|
||||
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
||||
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
||||
const selectedBudgetWorkingGroup =
|
||||
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
|
||||
const selectedBudgetReleaseGroup =
|
||||
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||
@@ -776,9 +805,12 @@ export function DashboardShell({
|
||||
selectedPeriodForManagement !== null &&
|
||||
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
|
||||
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
|
||||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt) ||
|
||||
periodEditForm.cutoffName.trim() !== selectedPeriodForManagement.cutoffName ||
|
||||
periodEditForm.cutoffDate !== (selectedPeriodForManagement.cutoffDate ? toDateInputValue(selectedPeriodForManagement.cutoffDate) : ""));
|
||||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
|
||||
const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? [];
|
||||
const currentCutoffs = currentPeriod?.cutoffs ?? [];
|
||||
const primaryCurrentCutoff = currentCutoffs[0] ?? null;
|
||||
const selectedExpenseCutoff =
|
||||
currentCutoffs.find((cutoff) => cutoff.id === expenseForm.cutoffId) ?? primaryCurrentCutoff;
|
||||
|
||||
const allExpenses = useMemo(
|
||||
() => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)),
|
||||
@@ -812,6 +844,23 @@ export function DashboardShell({
|
||||
}));
|
||||
}
|
||||
|
||||
function getCutoffDraft(cutoff: { id: string; name: string; date: string | null }): CutoffDraft {
|
||||
return cutoffDrafts[cutoff.id] ?? {
|
||||
name: cutoff.name,
|
||||
date: cutoff.date ? toDateInputValue(cutoff.date) : ""
|
||||
};
|
||||
}
|
||||
|
||||
function updateCutoffDraft(cutoff: { id: string; name: string; date: string | null }, patch: Partial<CutoffDraft>) {
|
||||
setCutoffDrafts((current) => ({
|
||||
...current,
|
||||
[cutoff.id]: {
|
||||
...getCutoffDraft(cutoff),
|
||||
...patch
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function getDonationDraft(donation: DashboardDonation): DonationDraft {
|
||||
return donationDrafts[donation.id] ?? {
|
||||
title: donation.title,
|
||||
@@ -893,10 +942,15 @@ export function DashboardShell({
|
||||
() =>
|
||||
allExpenses.reduce(
|
||||
(sum, expense) =>
|
||||
sum + (expense.cutoffPhase === "PRE" && !expense.paidAt ? expense.netPeriodAmount : 0),
|
||||
sum +
|
||||
(expense.cutoffPhase === "PRE" &&
|
||||
!expense.paidAt &&
|
||||
(!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
? expense.netPeriodAmount
|
||||
: 0),
|
||||
0
|
||||
),
|
||||
[allExpenses]
|
||||
[allExpenses, primaryCurrentCutoff]
|
||||
);
|
||||
const paidTotal = useMemo(
|
||||
() => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
@@ -960,6 +1014,7 @@ export function DashboardShell({
|
||||
budgetId: expenseForm.budgetId,
|
||||
recurrence: expenseForm.recurrence,
|
||||
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
|
||||
cutoffId: expenseForm.cutoffId,
|
||||
cutoffPhase: expenseForm.cutoffPhase
|
||||
})
|
||||
})
|
||||
@@ -976,6 +1031,7 @@ export function DashboardShell({
|
||||
budgetId: resetBudget,
|
||||
recurrence: "NONE",
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||
cutoffId: currentPeriod?.cutoffs[0]?.id ?? "",
|
||||
cutoffPhase: "PRE"
|
||||
});
|
||||
}, "Ausgabe wurde gespeichert.");
|
||||
@@ -1271,6 +1327,56 @@ export function DashboardShell({
|
||||
}, `Spende ${title} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
async function handleCreateCutoff(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedPeriodForManagement) {
|
||||
setMessage({ type: "error", text: "Bitte zuerst einen Zeitraum auswählen." });
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/periods/${selectedPeriodForManagement.id}/cutoffs`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(cutoffForm)
|
||||
})
|
||||
);
|
||||
setCutoffForm({
|
||||
name: "Open Air",
|
||||
date: ""
|
||||
});
|
||||
}, "Stichtag wurde angelegt.");
|
||||
}
|
||||
|
||||
async function handleUpdateCutoff(cutoffId: string, draft: CutoffDraft) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/period-cutoffs/${cutoffId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(draft)
|
||||
})
|
||||
);
|
||||
setEditingCutoffId(null);
|
||||
}, "Stichtag wurde bearbeitet.");
|
||||
}
|
||||
|
||||
async function handleDeleteCutoff(cutoffId: string, cutoffName: string) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/period-cutoffs/${cutoffId}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
);
|
||||
}, `Stichtag ${cutoffName} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1302,7 +1408,11 @@ export function DashboardShell({
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(periodEditForm)
|
||||
body: JSON.stringify({
|
||||
name: periodEditForm.name,
|
||||
startsAt: periodEditForm.startsAt,
|
||||
endsAt: periodEditForm.endsAt
|
||||
})
|
||||
})
|
||||
);
|
||||
}, `Zeitraum ${periodEditForm.name.trim() || selectedPeriodForManagement.name} wurde aktualisiert.`);
|
||||
@@ -1950,41 +2060,144 @@ export function DashboardShell({
|
||||
) : null;
|
||||
|
||||
const cutoffManagementPanel = canManagePeriods ? (
|
||||
<Box component="form" onSubmit={handleSavePeriod}>
|
||||
<Stack spacing={1.4}>
|
||||
<Stack spacing={1.6}>
|
||||
<Stack spacing={0.6}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Stichtage
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten.
|
||||
Stichtage für Pre/Post-Auswertungen anlegen, bearbeiten und löschen.
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Stichtag-Name"
|
||||
value={periodEditForm.cutoffName}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
value={periodEditForm.cutoffDate}
|
||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outlined"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
||||
>
|
||||
Stichtag speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
{managementCutoffs.length > 0 ? (
|
||||
managementCutoffs.map((cutoff) => {
|
||||
const draft = getCutoffDraft(cutoff);
|
||||
const isEditing = editingCutoffId === cutoff.id;
|
||||
|
||||
return (
|
||||
<Box key={cutoff.id} sx={nestedPanelSx}>
|
||||
{isEditing ? (
|
||||
<Stack spacing={1.2}>
|
||||
<TextField
|
||||
label="Stichtag-Name"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateCutoffDraft(cutoff, { name: event.target.value })}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
value={draft.date}
|
||||
onChange={(event) => updateCutoffDraft(cutoff, { date: event.target.value })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={busy || draft.name.trim().length === 0}
|
||||
onClick={() => handleUpdateCutoff(cutoff.id, draft)}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setEditingCutoffId(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" gap={1.2}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{cutoff.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{cutoff.date ? dateFormatter.format(new Date(cutoff.date)) : "Kein Datum gesetzt"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditingCutoffId(cutoff.id);
|
||||
setCutoffDrafts((current) => ({
|
||||
...current,
|
||||
[cutoff.id]: getCutoffDraft(cutoff)
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<DeleteOutlineRoundedIcon />}
|
||||
disabled={busy || managementCutoffs.length <= 1}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Stichtag "${cutoff.name}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
handleDeleteCutoff(cutoff.id, cutoff.name);
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Für diesen Zeitraum gibt es noch keine Stichtage.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box component="form" onSubmit={handleCreateCutoff} sx={nestedPanelSx}>
|
||||
<Stack spacing={1.2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Neuen Stichtag anlegen
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Stichtag-Name"
|
||||
value={cutoffForm.name}
|
||||
onChange={(event) => setCutoffForm((current) => ({ ...current, name: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
value={cutoffForm.date}
|
||||
onChange={(event) => setCutoffForm((current) => ({ ...current, date: event.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
disabled={!selectedPeriodForManagement}
|
||||
/>
|
||||
<Button type="submit" variant="outlined" disabled={busy || !selectedPeriodForManagement}>
|
||||
Stichtag anlegen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null;
|
||||
const actionCards = (
|
||||
<Stack
|
||||
@@ -2096,22 +2309,44 @@ export function DashboardShell({
|
||||
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
|
||||
/>
|
||||
) : null}
|
||||
<TextField
|
||||
select
|
||||
label={`Stichtag-Zuordnung (${currentPeriod?.cutoffName ?? "Open Air"})`}
|
||||
value={expenseForm.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"]
|
||||
}))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">{`Pre ${currentPeriod?.cutoffName ?? "Open Air"}`}</MenuItem>
|
||||
<MenuItem value="POST">{`Post ${currentPeriod?.cutoffName ?? "Open Air"}`}</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
value={expenseForm.cutoffId}
|
||||
onChange={(event) =>
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffId: event.target.value
|
||||
}))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
disabled={currentCutoffs.length === 0}
|
||||
>
|
||||
{currentCutoffs.map((cutoff) => (
|
||||
<MenuItem key={cutoff.id} value={cutoff.id}>
|
||||
{cutoff.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Zuordnung"
|
||||
value={expenseForm.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"]
|
||||
}))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">{`Pre ${selectedExpenseCutoff?.name ?? "Open Air"}`}</MenuItem>
|
||||
<MenuItem value="POST">{`Post ${selectedExpenseCutoff?.name ?? "Open Air"}`}</MenuItem>
|
||||
</TextField>
|
||||
</Stack>
|
||||
<TextField
|
||||
select
|
||||
label="Arbeitsgruppe"
|
||||
@@ -3250,6 +3485,7 @@ export function DashboardShell({
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
workingGroups={visibleGroups}
|
||||
cutoffs={currentCutoffs}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
@@ -3302,6 +3538,7 @@ export function DashboardShell({
|
||||
<BudgetColumn
|
||||
group={group}
|
||||
workingGroups={visibleGroups}
|
||||
cutoffs={currentCutoffs}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
@@ -3367,9 +3604,15 @@ export function DashboardShell({
|
||||
}
|
||||
|
||||
if (financeViewMode === "cutoff") {
|
||||
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
||||
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
||||
const cutoffDate = currentPeriod?.cutoffDate ? new Date(currentPeriod.cutoffDate) : null;
|
||||
const pre = allExpenses.filter(
|
||||
(expense) =>
|
||||
expense.cutoffPhase === "PRE" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
);
|
||||
const post = allExpenses.filter(
|
||||
(expense) =>
|
||||
expense.cutoffPhase === "POST" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
);
|
||||
const cutoffDate = primaryCurrentCutoff?.date ? new Date(primaryCurrentCutoff.date) : null;
|
||||
const preGeneralDonations = donations
|
||||
.filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate))
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
@@ -3384,14 +3627,14 @@ export function DashboardShell({
|
||||
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||
return [
|
||||
{
|
||||
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||
label: `Pre ${primaryCurrentCutoff?.name ?? "Open Air"}`,
|
||||
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
donations: preAssignedDonations + preGeneralDonations
|
||||
},
|
||||
{
|
||||
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||
label: `Post ${primaryCurrentCutoff?.name ?? "Open Air"}`,
|
||||
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
@@ -3659,7 +3902,7 @@ export function DashboardShell({
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Geplant bis ${currentPeriod?.cutoffName ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
|
||||
label={`Geplant bis ${primaryCurrentCutoff?.name ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
<Chip
|
||||
|
||||
@@ -15,6 +15,14 @@ export type DashboardAccountingPeriod = {
|
||||
isCurrent: boolean;
|
||||
cutoffName: string;
|
||||
cutoffDate: string | null;
|
||||
cutoffs: DashboardPeriodCutoff[];
|
||||
};
|
||||
|
||||
export type DashboardPeriodCutoff = {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string | null;
|
||||
periodId: string;
|
||||
};
|
||||
|
||||
export type DashboardViewer = {
|
||||
@@ -79,6 +87,7 @@ export type DashboardExpense = {
|
||||
approvalStatus: ApprovalStatusValue;
|
||||
recurrence: ExpenseRecurrenceValue;
|
||||
recurrenceStartAt: string | null;
|
||||
cutoffId: string | null;
|
||||
cutoffPhase: CutoffPhaseValue;
|
||||
donationAmount: number;
|
||||
donations: DashboardExpenseDonation[];
|
||||
|
||||
Reference in New Issue
Block a user