Finanzuebersicht Stichtage und Spenden ergaenzen
All checks were successful
CI / Build and Deploy (push) Successful in 3m11s
All checks were successful
CI / Build and Deploy (push) Successful in 3m11s
This commit is contained in:
@@ -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;
|
||||||
@@ -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,8 +84,11 @@ 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")
|
||||||
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")
|
||||||
|
|
||||||
@@ -139,6 +148,7 @@ 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")
|
||||||
|
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")
|
||||||
@@ -149,10 +159,32 @@ model Expense {
|
|||||||
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
||||||
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")
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ 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),
|
||||||
|
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()])
|
||||||
@@ -114,6 +115,7 @@ export async function POST(request: Request) {
|
|||||||
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${expense.id}`;
|
||||||
|
|
||||||
if (needsManualApproval) {
|
if (needsManualApproval) {
|
||||||
await notifyApprovalRequest(
|
await notifyApprovalRequest(
|
||||||
@@ -140,12 +142,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,45 @@ export default async function DashboardPage() {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const periodCutoffs = await prisma.$queryRaw<
|
||||||
|
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
|
||||||
|
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
|
||||||
|
const expenseCutoffs = await prisma.$queryRaw<
|
||||||
|
{ id: string; cutoff_phase: "PRE" | "POST" }[]
|
||||||
|
>`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
|
||||||
|
const donationRows = await prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
donated_at: Date;
|
||||||
|
period_id: string;
|
||||||
|
expense_id: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
creator_id: string;
|
||||||
|
creator_name: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
|
||||||
|
u.id AS creator_id, u.username AS creator_name
|
||||||
|
FROM donations d
|
||||||
|
JOIN users u ON u.id = d.creator_id
|
||||||
|
WHERE d.period_id = ${currentPeriod.id}
|
||||||
|
ORDER BY d.donated_at DESC
|
||||||
|
`;
|
||||||
|
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
|
||||||
|
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
|
||||||
|
const donationsByExpenseId = new Map<string, number>();
|
||||||
|
for (const donation of donationRows) {
|
||||||
|
if (donation.expense_id) {
|
||||||
|
donationsByExpenseId.set(
|
||||||
|
donation.expense_id,
|
||||||
|
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const serializedViewer: DashboardViewer = {
|
const serializedViewer: DashboardViewer = {
|
||||||
id: viewer.id,
|
id: viewer.id,
|
||||||
name: viewer.username,
|
name: viewer.username,
|
||||||
@@ -197,6 +237,13 @@ export default async function DashboardPage() {
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt,
|
recurrenceStartAt,
|
||||||
|
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
|
||||||
|
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
|
||||||
|
netPeriodAmount: Math.max(
|
||||||
|
0,
|
||||||
|
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -
|
||||||
|
(donationsByExpenseId.get(expense.id) ?? 0)
|
||||||
|
),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
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) => ({
|
||||||
@@ -249,7 +296,24 @@ export default async function DashboardPage() {
|
|||||||
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: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air",
|
||||||
|
cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({
|
||||||
|
id: donation.id,
|
||||||
|
title: donation.title,
|
||||||
|
description: donation.description,
|
||||||
|
amount: Number(donation.amount),
|
||||||
|
donatedAt: donation.donated_at.toISOString(),
|
||||||
|
periodId: donation.period_id,
|
||||||
|
expenseId: donation.expense_id,
|
||||||
|
createdAt: donation.created_at.toISOString(),
|
||||||
|
creator: {
|
||||||
|
id: donation.creator_id,
|
||||||
|
name: donation.creator_name
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
||||||
@@ -282,6 +346,7 @@ export default async function DashboardPage() {
|
|||||||
accountingPeriods={serializedPeriods}
|
accountingPeriods={serializedPeriods}
|
||||||
currentPeriodId={currentPeriod.id}
|
currentPeriodId={currentPeriod.id}
|
||||||
settings={serializedSettings}
|
settings={serializedSettings}
|
||||||
|
donations={serializedDonations}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,15 +127,15 @@ 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({
|
||||||
@@ -166,11 +166,11 @@ export function BudgetColumn({
|
|||||||
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 budgetCardWidth = 352;
|
const budgetCardWidth = 318;
|
||||||
const desktopBudgetGap = 16;
|
const desktopBudgetGap = 14;
|
||||||
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 + 58, 410);
|
||||||
const canEditBudgets = canManageBudgets(viewer.role);
|
const canEditBudgets = canManageBudgets(viewer.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -766,10 +766,15 @@ export function BudgetColumn({
|
|||||||
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
{isRecurringSeries
|
{isRecurringSeries
|
||||||
? expense.occurrenceCount > 0
|
? expense.occurrenceCount > 0
|
||||||
? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
? `${formatCurrency(expense.netPeriodAmount)} netto im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
||||||
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}`
|
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}`
|
||||||
: `${formatCurrency(expense.amount)} von ${expense.creator.name}`}
|
: `${formatCurrency(expense.netPeriodAmount)} netto von ${expense.creator.name}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{expense.donationAmount > 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<StatusChips expense={expense} />
|
<StatusChips expense={expense} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
|||||||
import type {
|
import type {
|
||||||
DashboardAccountingPeriod,
|
DashboardAccountingPeriod,
|
||||||
DashboardAuditLog,
|
DashboardAuditLog,
|
||||||
|
DashboardDonation,
|
||||||
DashboardManagedUser,
|
DashboardManagedUser,
|
||||||
DashboardSettings,
|
DashboardSettings,
|
||||||
DashboardViewer,
|
DashboardViewer,
|
||||||
@@ -66,6 +67,7 @@ type DashboardShellProps = {
|
|||||||
accountingPeriods: DashboardAccountingPeriod[];
|
accountingPeriods: DashboardAccountingPeriod[];
|
||||||
currentPeriodId: string;
|
currentPeriodId: string;
|
||||||
settings: DashboardSettings;
|
settings: DashboardSettings;
|
||||||
|
donations: DashboardDonation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExpenseFormState = {
|
type ExpenseFormState = {
|
||||||
@@ -76,6 +78,7 @@ type ExpenseFormState = {
|
|||||||
budgetId: string;
|
budgetId: string;
|
||||||
recurrence: "NONE" | "MONTHLY";
|
recurrence: "NONE" | "MONTHLY";
|
||||||
recurrenceStartAt: string;
|
recurrenceStartAt: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
};
|
};
|
||||||
|
|
||||||
type BudgetFormState = {
|
type BudgetFormState = {
|
||||||
@@ -91,6 +94,15 @@ type BudgetReleaseFormState = {
|
|||||||
releasedAmount: string;
|
releasedAmount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DonationFormState = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
donatedAt: string;
|
||||||
|
target: "GENERAL" | "EXPENSE";
|
||||||
|
expenseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type WorkingGroupFormState = {
|
type WorkingGroupFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
@@ -120,6 +132,8 @@ type PeriodEditFormState = {
|
|||||||
name: string;
|
name: string;
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
|
cutoffName: string;
|
||||||
|
cutoffDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrgaSettingsDraft = {
|
type OrgaSettingsDraft = {
|
||||||
@@ -172,9 +186,10 @@ function sortManagedUsersList(users: DashboardManagedUser[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type MobileSection = "overview" | "actions";
|
type MobileSection = "overview" | "finance" | "actions";
|
||||||
type MobileAction =
|
type MobileAction =
|
||||||
| "expense"
|
| "expense"
|
||||||
|
| "donation"
|
||||||
| "budgetRelease"
|
| "budgetRelease"
|
||||||
| "workingGroup"
|
| "workingGroup"
|
||||||
| "budget"
|
| "budget"
|
||||||
@@ -184,7 +199,9 @@ type MobileAction =
|
|||||||
| "approvalThreshold"
|
| "approvalThreshold"
|
||||||
| "users"
|
| "users"
|
||||||
| "logs";
|
| "logs";
|
||||||
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
|
type FinanceViewMode = "monthly" | "yearly" | "cutoff";
|
||||||
|
type FinancePresentation = "charts" | "table";
|
||||||
|
type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs";
|
||||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR"
|
currency: "EUR"
|
||||||
@@ -234,14 +251,18 @@ function getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined
|
|||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
startsAt: "",
|
startsAt: "",
|
||||||
endsAt: ""
|
endsAt: "",
|
||||||
|
cutoffName: "Open Air",
|
||||||
|
cutoffDate: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: period.name,
|
name: period.name,
|
||||||
startsAt: toDateInputValue(period.startsAt),
|
startsAt: toDateInputValue(period.startsAt),
|
||||||
endsAt: toDateInputValue(period.endsAt)
|
endsAt: toDateInputValue(period.endsAt),
|
||||||
|
cutoffName: period.cutoffName || "Open Air",
|
||||||
|
cutoffDate: period.cutoffDate ? toDateInputValue(period.cutoffDate) : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +316,8 @@ export function DashboardShell({
|
|||||||
auditLogs,
|
auditLogs,
|
||||||
accountingPeriods,
|
accountingPeriods,
|
||||||
currentPeriodId,
|
currentPeriodId,
|
||||||
settings
|
settings,
|
||||||
|
donations
|
||||||
}: DashboardShellProps) {
|
}: DashboardShellProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
@@ -313,7 +335,8 @@ export function DashboardShell({
|
|||||||
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
||||||
const approvalThreshold = settings.approvalThreshold;
|
const approvalThreshold = settings.approvalThreshold;
|
||||||
const desktopSections = [
|
const desktopSections = [
|
||||||
{ value: "overview" as const, label: "\u00dcbersicht" },
|
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
|
||||||
|
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
|
||||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
||||||
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
||||||
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
||||||
@@ -321,6 +344,7 @@ export function DashboardShell({
|
|||||||
];
|
];
|
||||||
const mobileActions = [
|
const mobileActions = [
|
||||||
{ value: "expense" as const, label: "Neue Ausgabe" },
|
{ value: "expense" as const, label: "Neue Ausgabe" },
|
||||||
|
...(canManagePeriods ? [{ value: "donation" as const, label: "Spende erfassen" }] : []),
|
||||||
...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []),
|
...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []),
|
||||||
...(canManageBudgets(viewer.role)
|
...(canManageBudgets(viewer.role)
|
||||||
? [
|
? [
|
||||||
@@ -354,7 +378,16 @@ export function DashboardShell({
|
|||||||
agId: defaultEditableGroup?.id ?? "",
|
agId: defaultEditableGroup?.id ?? "",
|
||||||
budgetId: defaultBudget?.id ?? "",
|
budgetId: defaultBudget?.id ?? "",
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||||
|
cutoffPhase: "PRE"
|
||||||
|
});
|
||||||
|
const [donationForm, setDonationForm] = useState<DonationFormState>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
amount: "",
|
||||||
|
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||||
|
target: "GENERAL",
|
||||||
|
expenseId: ""
|
||||||
});
|
});
|
||||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||||
@@ -374,13 +407,15 @@ export function DashboardShell({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
workingGroupId: ""
|
||||||
});
|
});
|
||||||
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
||||||
const [selectedMobileAction, setSelectedMobileAction] = useState<MobileAction>("expense");
|
const [selectedMobileAction, setSelectedMobileAction] = useState<MobileAction>("expense");
|
||||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||||
|
const [financeViewMode, setFinanceViewMode] = useState<FinanceViewMode>("monthly");
|
||||||
|
const [financePresentation, setFinancePresentation] = useState<FinancePresentation>("charts");
|
||||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||||
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
||||||
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
|
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
|
||||||
@@ -576,7 +611,7 @@ export function DashboardShell({
|
|||||||
if (!groupStillExists) {
|
if (!groupStillExists) {
|
||||||
setBudgetForm((current) => ({
|
setBudgetForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
workingGroupId: ""
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [budgetForm.workingGroupId, visibleGroups]);
|
}, [budgetForm.workingGroupId, visibleGroups]);
|
||||||
@@ -667,12 +702,13 @@ export function DashboardShell({
|
|||||||
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
const groupStillExists =
|
||||||
|
userForm.workingGroupId === "" || visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
||||||
|
|
||||||
if (!groupStillExists) {
|
if (!groupStillExists) {
|
||||||
setUserForm((current) => ({
|
setUserForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
workingGroupId: ""
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [userForm.workingGroupId, visibleGroups]);
|
}, [userForm.workingGroupId, visibleGroups]);
|
||||||
@@ -711,7 +747,7 @@ export function DashboardShell({
|
|||||||
null;
|
null;
|
||||||
const selectedBudgetReleasePaidAmount =
|
const selectedBudgetReleasePaidAmount =
|
||||||
selectedBudgetReleaseBudget?.expenses.reduce(
|
selectedBudgetReleaseBudget?.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0),
|
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
|
||||||
0
|
0
|
||||||
) ?? 0;
|
) ?? 0;
|
||||||
const selectedPeriodForManagement =
|
const selectedPeriodForManagement =
|
||||||
@@ -720,7 +756,14 @@ export function DashboardShell({
|
|||||||
selectedPeriodForManagement !== null &&
|
selectedPeriodForManagement !== null &&
|
||||||
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
|
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
|
||||||
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
|
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
|
||||||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
|
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt) ||
|
||||||
|
periodEditForm.cutoffName.trim() !== selectedPeriodForManagement.cutoffName ||
|
||||||
|
periodEditForm.cutoffDate !== (selectedPeriodForManagement.cutoffDate ? toDateInputValue(selectedPeriodForManagement.cutoffDate) : ""));
|
||||||
|
|
||||||
|
const allExpenses = useMemo(
|
||||||
|
() => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)),
|
||||||
|
[visibleGroups]
|
||||||
|
);
|
||||||
|
|
||||||
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
||||||
return userDrafts[user.id] ?? {
|
return userDrafts[user.id] ?? {
|
||||||
@@ -755,7 +798,7 @@ export function DashboardShell({
|
|||||||
(groupSum, budget) =>
|
(groupSum, budget) =>
|
||||||
groupSum +
|
groupSum +
|
||||||
budget.expenses.reduce(
|
budget.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0),
|
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0),
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
@@ -764,7 +807,7 @@ export function DashboardShell({
|
|||||||
(groupSum, budget) =>
|
(groupSum, budget) =>
|
||||||
groupSum +
|
groupSum +
|
||||||
budget.expenses.reduce(
|
budget.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0),
|
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0),
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
@@ -796,6 +839,23 @@ export function DashboardShell({
|
|||||||
);
|
);
|
||||||
}, [visibleGroups]);
|
}, [visibleGroups]);
|
||||||
|
|
||||||
|
const generalDonationTotal = useMemo(
|
||||||
|
() => donations.reduce((sum, donation) => sum + (donation.expenseId ? 0 : donation.amount), 0),
|
||||||
|
[donations]
|
||||||
|
);
|
||||||
|
const assignedDonationTotal = useMemo(
|
||||||
|
() => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0),
|
||||||
|
[donations]
|
||||||
|
);
|
||||||
|
const preCutoffExpenses = useMemo(
|
||||||
|
() => allExpenses.reduce((sum, expense) => sum + (expense.cutoffPhase === "PRE" ? expense.netPeriodAmount : 0), 0),
|
||||||
|
[allExpenses]
|
||||||
|
);
|
||||||
|
const paidTotal = useMemo(
|
||||||
|
() => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||||
|
[allExpenses]
|
||||||
|
);
|
||||||
|
|
||||||
async function runAction<T>(
|
async function runAction<T>(
|
||||||
task: () => Promise<T>,
|
task: () => Promise<T>,
|
||||||
successMessage: string | ((result: T) => string)
|
successMessage: string | ((result: T) => string)
|
||||||
@@ -852,7 +912,8 @@ export function DashboardShell({
|
|||||||
agId: expenseForm.agId,
|
agId: expenseForm.agId,
|
||||||
budgetId: expenseForm.budgetId,
|
budgetId: expenseForm.budgetId,
|
||||||
recurrence: expenseForm.recurrence,
|
recurrence: expenseForm.recurrence,
|
||||||
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : ""
|
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
|
||||||
|
cutoffPhase: expenseForm.cutoffPhase
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -867,7 +928,8 @@ export function DashboardShell({
|
|||||||
agId: resetGroup,
|
agId: resetGroup,
|
||||||
budgetId: resetBudget,
|
budgetId: resetBudget,
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||||
|
cutoffPhase: "PRE"
|
||||||
});
|
});
|
||||||
}, "Ausgabe wurde gespeichert.");
|
}, "Ausgabe wurde gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -1054,6 +1116,48 @@ export function DashboardShell({
|
|||||||
}, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`);
|
}, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateDonation(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!currentPeriod) {
|
||||||
|
setMessage({ type: "error", text: "Bitte zuerst einen aktuellen Zeitraum auswählen." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (donationForm.target === "EXPENSE" && !donationForm.expenseId) {
|
||||||
|
setMessage({ type: "error", text: "Bitte die Ausgabe auswählen, der die Spende zugeordnet werden soll." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAction(async () => {
|
||||||
|
await parseResponse(
|
||||||
|
await fetch("/api/donations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: donationForm.title,
|
||||||
|
description: donationForm.description,
|
||||||
|
amount: donationForm.amount,
|
||||||
|
donatedAt: donationForm.donatedAt,
|
||||||
|
periodId: currentPeriod.id,
|
||||||
|
expenseId: donationForm.target === "EXPENSE" ? donationForm.expenseId : ""
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setDonationForm({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
amount: "",
|
||||||
|
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||||
|
target: "GENERAL",
|
||||||
|
expenseId: ""
|
||||||
|
});
|
||||||
|
}, "Spende wurde erfasst.");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteBudget(budgetId: string) {
|
async function handleDeleteBudget(budgetId: string) {
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await parseResponse(
|
await parseResponse(
|
||||||
@@ -1207,7 +1311,7 @@ export function DashboardShell({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
workingGroupId: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1692,6 +1796,44 @@ export function DashboardShell({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Stichtage
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Dieser Stichtag trennt Ausgaben in Pre/Post und wird in der Finanzübersicht ausgewertet.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditRoundedIcon />}
|
||||||
|
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
||||||
|
>
|
||||||
|
Stichtag speichern
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
||||||
<Stack spacing={1.4}>
|
<Stack spacing={1.4}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
@@ -1798,6 +1940,12 @@ export function DashboardShell({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{viewer.role === "MEMBER" && !viewer.workingGroupId ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
Du bist noch keiner AG zugeordnet. Du kannst dich anmelden, aber Ausgaben erst erfassen, wenn dir eine AG zugewiesen wurde.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleCreateExpense}>
|
<Box component="form" onSubmit={handleCreateExpense}>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1855,6 +2003,22 @@ export function DashboardShell({
|
|||||||
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
|
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Arbeitsgruppe"
|
label="Arbeitsgruppe"
|
||||||
@@ -2002,6 +2166,96 @@ export function DashboardShell({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canManagePeriods && (isCompactLayout ? selectedMobileAction === "donation" : desktopSection === "overview") ? (
|
||||||
|
<Card sx={islandCardSx}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
|
||||||
|
Spende erfassen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: "0.9rem" }}>
|
||||||
|
Allgemeine Spenden zählen global, zugeordnete Spenden entlasten direkt eine Ausgabe.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box component="form" onSubmit={handleCreateDonation}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
value={donationForm.title}
|
||||||
|
onChange={(event) => setDonationForm((current) => ({ ...current, title: event.target.value }))}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
value={donationForm.description}
|
||||||
|
onChange={(event) => setDonationForm((current) => ({ ...current, description: event.target.value }))}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Betrag in EUR"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
value={donationForm.amount}
|
||||||
|
onChange={(event) => setDonationForm((current) => ({ ...current, amount: event.target.value }))}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Spendendatum"
|
||||||
|
type="date"
|
||||||
|
value={donationForm.donatedAt}
|
||||||
|
onChange={(event) => setDonationForm((current) => ({ ...current, donatedAt: event.target.value }))}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Zuordnung"
|
||||||
|
value={donationForm.target}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDonationForm((current) => ({
|
||||||
|
...current,
|
||||||
|
target: event.target.value as DonationFormState["target"],
|
||||||
|
expenseId: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="GENERAL">Allgemein</MenuItem>
|
||||||
|
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{donationForm.target === "EXPENSE" ? (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Ausgabe"
|
||||||
|
value={donationForm.expenseId}
|
||||||
|
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={allExpenses.length === 0}
|
||||||
|
>
|
||||||
|
{allExpenses.map((expense) => (
|
||||||
|
<MenuItem key={expense.id} value={expense.id}>
|
||||||
|
{expense.title} · {currencyFormatter.format(expense.netPeriodAmount)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" variant="outlined" disabled={busy}>
|
||||||
|
Spende speichern
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? (
|
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? (
|
||||||
<Card sx={islandCardSx}>
|
<Card sx={islandCardSx}>
|
||||||
<CardContent sx={{ p: 3 }}>
|
<CardContent sx={{ p: 3 }}>
|
||||||
@@ -2249,16 +2503,15 @@ export function DashboardShell({
|
|||||||
}
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={visibleGroups.length === 0}
|
disabled={visibleGroups.length === 0}
|
||||||
required={userForm.role === "MEMBER"}
|
helperText={
|
||||||
helperText={
|
visibleGroups.length === 0
|
||||||
visibleGroups.length === 0
|
? "Lege zuerst eine AG an."
|
||||||
? "Lege zuerst eine AG an."
|
: userForm.role === "MEMBER"
|
||||||
: userForm.role === "MEMBER"
|
? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen."
|
||||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
}
|
||||||
}
|
>
|
||||||
>
|
<MenuItem value="">Ohne AG</MenuItem>
|
||||||
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<MenuItem key={group.id} value={group.id}>
|
<MenuItem key={group.id} value={group.id}>
|
||||||
{group.name}
|
{group.name}
|
||||||
@@ -2453,16 +2706,15 @@ export function DashboardShell({
|
|||||||
onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })}
|
onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={visibleGroups.length === 0}
|
disabled={visibleGroups.length === 0}
|
||||||
required={draft.role === "MEMBER"}
|
helperText={
|
||||||
helperText={
|
visibleGroups.length === 0
|
||||||
visibleGroups.length === 0
|
? "Lege zuerst eine AG an."
|
||||||
? "Lege zuerst eine AG an."
|
: draft.role === "MEMBER"
|
||||||
: draft.role === "MEMBER"
|
? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen."
|
||||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
}
|
||||||
}
|
>
|
||||||
>
|
<MenuItem value="">Ohne AG</MenuItem>
|
||||||
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<MenuItem key={group.id} value={group.id}>
|
<MenuItem key={group.id} value={group.id}>
|
||||||
{group.name}
|
{group.name}
|
||||||
@@ -2780,6 +3032,167 @@ export function DashboardShell({
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const financeRows = (() => {
|
||||||
|
if (financeViewMode === "monthly") {
|
||||||
|
const rows = new Map<string, { label: string; planned: number; approved: number; paid: number; donations: number }>();
|
||||||
|
for (const expense of allExpenses) {
|
||||||
|
const date = new Date(expense.createdAt);
|
||||||
|
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const row = rows.get(key) ?? {
|
||||||
|
label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date),
|
||||||
|
planned: 0,
|
||||||
|
approved: 0,
|
||||||
|
paid: 0,
|
||||||
|
donations: 0
|
||||||
|
};
|
||||||
|
if (expense.approvalStatus === "PENDING") row.planned += expense.netPeriodAmount;
|
||||||
|
if (expense.approvalStatus === "APPROVED") row.approved += expense.netPeriodAmount;
|
||||||
|
if (expense.paidAt) row.paid += expense.netPeriodAmount;
|
||||||
|
rows.set(key, row);
|
||||||
|
}
|
||||||
|
for (const donation of donations) {
|
||||||
|
const date = new Date(donation.donatedAt);
|
||||||
|
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const row = rows.get(key) ?? {
|
||||||
|
label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date),
|
||||||
|
planned: 0,
|
||||||
|
approved: 0,
|
||||||
|
paid: 0,
|
||||||
|
donations: 0
|
||||||
|
};
|
||||||
|
row.donations += donation.amount;
|
||||||
|
rows.set(key, row);
|
||||||
|
}
|
||||||
|
return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, row]) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (financeViewMode === "cutoff") {
|
||||||
|
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
||||||
|
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||||
|
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||||
|
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||||
|
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||||
|
donations: assignedDonationTotal
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||||
|
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||||
|
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||||
|
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||||
|
donations: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Allgemeine Spenden",
|
||||||
|
planned: 0,
|
||||||
|
approved: 0,
|
||||||
|
paid: 0,
|
||||||
|
donations: generalDonationTotal
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: currentPeriod?.name ?? "Jahresübersicht",
|
||||||
|
planned: totals.pending,
|
||||||
|
approved: totals.approved,
|
||||||
|
paid: paidTotal,
|
||||||
|
donations: generalDonationTotal + assignedDonationTotal
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const financeOverviewContent = (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Card sx={islandCardSx}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} gap={1.2}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Ansicht auswählen"
|
||||||
|
value={financeViewMode}
|
||||||
|
onChange={(event) => setFinanceViewMode(event.target.value as FinanceViewMode)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="monthly">Monatsübersichten</MenuItem>
|
||||||
|
<MenuItem value="yearly">Jahresübersicht</MenuItem>
|
||||||
|
<MenuItem value="cutoff">Jahresübersicht Pre/Post</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Darstellung"
|
||||||
|
value={financePresentation}
|
||||||
|
onChange={(event) => setFinancePresentation(event.target.value as FinancePresentation)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="charts">Grafisch</MenuItem>
|
||||||
|
<MenuItem value="table">Tabellarisch</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
|
||||||
|
<Chip label={`Bezahlt netto: ${currencyFormatter.format(paidTotal)}`} color="info" />
|
||||||
|
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
|
||||||
|
<Chip label={`Netto-Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", lg: financePresentation === "charts" ? "repeat(3, minmax(0, 1fr))" : "1fr" },
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{financeRows.map((row) => {
|
||||||
|
const maxValue = Math.max(row.planned, row.approved, row.paid, row.donations, 1);
|
||||||
|
return (
|
||||||
|
<Card key={row.label} sx={islandCardSx}>
|
||||||
|
<CardContent sx={{ p: 2.5 }}>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
|
||||||
|
{row.label}
|
||||||
|
</Typography>
|
||||||
|
{(["planned", "approved", "paid", "donations"] as const).map((key) => {
|
||||||
|
const label =
|
||||||
|
key === "planned" ? "Geplant" : key === "approved" ? "Freigegeben" : key === "paid" ? "Bezahlt" : "Spenden";
|
||||||
|
const value = row[key];
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" gap={1}>
|
||||||
|
<Typography variant="body2">{label}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{currencyFormatter.format(value)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{financePresentation === "charts" ? (
|
||||||
|
<Box sx={{ height: 8, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.1), overflow: "hidden" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${Math.min((value / maxValue) * 100, 100)}%`,
|
||||||
|
height: "100%",
|
||||||
|
bgcolor: key === "donations" ? theme.palette.success.main : key === "paid" ? theme.palette.info.main : theme.palette.primary.main
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
const desktopSectionContent =
|
const desktopSectionContent =
|
||||||
desktopSection === "overview" ? (
|
desktopSection === "overview" ? (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -2791,6 +3204,8 @@ export function DashboardShell({
|
|||||||
<Box sx={{ width: { xs: "100%", xl: 380 }, flexShrink: 0 }}>{actionCards}</Box>
|
<Box sx={{ width: { xs: "100%", xl: 380 }, flexShrink: 0 }}>{actionCards}</Box>
|
||||||
<Box sx={{ flex: "1 1 0%", minWidth: 0, maxWidth: "100%", overflowX: "hidden" }}>{overviewContent}</Box>
|
<Box sx={{ flex: "1 1 0%", minWidth: 0, maxWidth: "100%", overflowX: "hidden" }}>{overviewContent}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : desktopSection === "finance" ? (
|
||||||
|
financeOverviewContent
|
||||||
) : desktopSection === "periods" ? (
|
) : desktopSection === "periods" ? (
|
||||||
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
|
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
|
||||||
{canManagePeriods ? (
|
{canManagePeriods ? (
|
||||||
@@ -2902,6 +3317,10 @@ export function DashboardShell({
|
|||||||
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
|
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
|
||||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||||
/>
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`Ausgaben bis ${currentPeriod?.cutoffName ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
|
||||||
|
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||||
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
|
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
|
||||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||||
@@ -3002,11 +3421,12 @@ export function DashboardShell({
|
|||||||
onChange={(_, nextValue: MobileSection) => setMobileSection(nextValue)}
|
onChange={(_, nextValue: MobileSection) => setMobileSection(nextValue)}
|
||||||
variant="fullWidth"
|
variant="fullWidth"
|
||||||
>
|
>
|
||||||
<Tab value="overview" label={"\u00dcbersicht"} />
|
<Tab value="overview" label={"AG-\u00dcbersicht"} />
|
||||||
|
<Tab value="finance" label={"Finanz\u00fcbersicht"} />
|
||||||
<Tab value="actions" label="Aktionen" />
|
<Tab value="actions" label="Aktionen" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
{mobileSection === "overview" ? overviewContent : actionCards}
|
{mobileSection === "overview" ? overviewContent : mobileSection === "finance" ? financeOverviewContent : actionCards}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ width: "100%" }}>{desktopSectionContent}</Box>
|
<Box sx={{ width: "100%" }}>{desktopSectionContent}</Box>
|
||||||
|
|||||||
@@ -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,8 @@ export type DashboardAccountingPeriod = {
|
|||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
cutoffName: string;
|
||||||
|
cutoffDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardViewer = {
|
export type DashboardViewer = {
|
||||||
@@ -68,6 +71,9 @@ export type DashboardExpense = {
|
|||||||
approvalStatus: ApprovalStatusValue;
|
approvalStatus: ApprovalStatusValue;
|
||||||
recurrence: ExpenseRecurrenceValue;
|
recurrence: ExpenseRecurrenceValue;
|
||||||
recurrenceStartAt: string | null;
|
recurrenceStartAt: string | null;
|
||||||
|
cutoffPhase: CutoffPhaseValue;
|
||||||
|
donationAmount: number;
|
||||||
|
netPeriodAmount: number;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
documentedAt: string | null;
|
documentedAt: string | null;
|
||||||
documents: DashboardExpenseDocument[];
|
documents: DashboardExpenseDocument[];
|
||||||
@@ -79,6 +85,21 @@ 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;
|
||||||
|
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) {
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user