Files
RFP_Finanzen/src/app/page.tsx
jan 5591d10d96
All checks were successful
CI / Build and Deploy (push) Successful in 2m43s
Mehrere Stichtage pro Zeitraum verwalten
2026-05-12 01:37:28 +02:00

391 lines
13 KiB
TypeScript

import { redirect } from "next/navigation";
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
import { getCurrentAccountingPeriod } from "@/lib/accounting-periods";
import { getAppSettings, serializeAppSettings } from "@/lib/app-settings";
import { getRollbackMetadata } from "@/lib/audit-log";
import type {
DashboardAccountingPeriod,
DashboardAuditLog,
DashboardDonation,
DashboardManagedUser,
DashboardSettings,
DashboardViewer,
DashboardWorkingGroup
} from "@/lib/dashboard-types";
import { canManageUsers, normalizeApprovalPermissions } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { buildRecurringOccurrences, getExpensePeriodAmount } from "@/lib/recurring-expenses";
import { getCurrentViewer } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const viewer = await getCurrentViewer();
if (!viewer) {
redirect("/login");
}
const [currentPeriod, appSettings] = await Promise.all([getCurrentAccountingPeriod(), getAppSettings()]);
if (!currentPeriod) {
throw new Error("Kein Abrechnungszeitraum gefunden.");
}
const accountingPeriods = await prisma.accountingPeriod.findMany({
orderBy: {
startsAt: "desc"
}
});
const workingGroups = await prisma.workingGroup.findMany({
include: {
members: {
select: {
id: true,
name: true,
username: true,
role: true
}
},
budgets: {
where: {
periodId: currentPeriod.id
},
orderBy: {
name: "asc"
},
include: {
expenses: {
orderBy: {
createdAt: "desc"
},
include: {
creator: {
select: {
id: true,
username: true
}
},
approvals: {
orderBy: {
timestamp: "asc"
},
include: {
user: {
select: {
id: true,
username: true
}
}
}
},
documents: {
orderBy: {
createdAt: "asc"
},
include: {
uploadedBy: {
select: {
id: true,
username: true
}
}
}
}
}
}
}
}
},
orderBy: {
name: "asc"
}
});
const managedUsers = canManageUsers(viewer.role)
? await prisma.user.findMany({
include: {
workingGroup: {
select: {
name: true
}
},
_count: {
select: {
approvals: true,
createdExpenses: true
}
}
},
orderBy: [{ role: "asc" }, { username: "asc" }]
})
: [];
const auditLogs = canManageUsers(viewer.role)
? await prisma.auditLog.findMany({
orderBy: {
createdAt: "desc"
},
take: 120,
include: {
actor: {
select: {
id: true,
name: true,
username: true,
role: true
}
}
}
})
: [];
const periodCutoffs = await prisma.$queryRaw<
{ id: string; name: string; date: Date | null; period_id: string }[]
>`
SELECT id, name, date, period_id
FROM period_cutoffs
ORDER BY date ASC NULLS LAST, created_at ASC
`;
const expenseCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[]
>`SELECT id, cutoff_id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
const donationRows = await prisma.$queryRaw<
{
id: string;
title: string;
description: string | null;
amount: unknown;
donated_at: Date;
period_id: string;
expense_id: string | null;
created_at: Date;
creator_id: string;
creator_name: string;
working_group_id: string | null;
working_group_name: string | null;
expense_title: string | null;
}[]
>`
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
u.id AS creator_id, u.username AS creator_name,
wg.id AS working_group_id, wg.name AS working_group_name, e.title AS expense_title
FROM donations d
JOIN users u ON u.id = d.creator_id
LEFT JOIN expenses e ON e.id = d.expense_id
LEFT JOIN working_groups wg ON wg.id = e.ag_id
WHERE d.period_id = ${currentPeriod.id}
ORDER BY d.donated_at DESC
`;
const periodCutoffsByPeriodId = new Map<string, typeof periodCutoffs>();
for (const cutoff of periodCutoffs) {
periodCutoffsByPeriodId.set(cutoff.period_id, [...(periodCutoffsByPeriodId.get(cutoff.period_id) ?? []), cutoff]);
}
const primaryCutoffByPeriodId = new Map(
[...periodCutoffsByPeriodId.entries()].map(([periodId, cutoffs]) => [periodId, cutoffs[0]])
);
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense]));
const donationsByExpenseId = new Map<string, number>();
const donationRowsByExpenseId = new Map<string, typeof donationRows>();
for (const donation of donationRows) {
if (donation.expense_id) {
donationsByExpenseId.set(
donation.expense_id,
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
);
donationRowsByExpenseId.set(donation.expense_id, [
...(donationRowsByExpenseId.get(donation.expense_id) ?? []),
donation
]);
}
}
const serializedViewer: DashboardViewer = {
id: viewer.id,
name: viewer.username,
username: viewer.username,
role: viewer.role,
workingGroupId: viewer.workingGroupId,
approvalPermissions: normalizeApprovalPermissions(viewer.role, viewer.approvalPermissions, viewer.approvalPreference)
};
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
id: workingGroup.id,
name: workingGroup.name,
totalBudget: workingGroup.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0),
members: workingGroup.members.map((member) => ({
id: member.id,
name: member.username,
username: member.username,
role: member.role
})),
budgets: workingGroup.budgets.map((budget) => ({
id: budget.id,
name: budget.name,
totalBudget: Number(budget.totalBudget),
releasedAmount: Number(budget.releasedAmount),
colorCode: budget.colorCode,
periodId: budget.periodId,
expenses: budget.expenses.map((expense) => {
const amount = Number(expense.amount);
const recurrenceStartAt =
expense.recurrence === "MONTHLY"
? (expense.recurrenceStartAt ?? expense.createdAt).toISOString()
: null;
const occurrences =
expense.recurrence === "MONTHLY" && recurrenceStartAt
? buildRecurringOccurrences({
expenseId: expense.id,
amount,
recurrenceStartAt,
periodStartsAt: currentPeriod.startsAt,
periodEndsAt: currentPeriod.endsAt
})
: [];
return {
id: expense.id,
title: expense.title,
description: expense.description,
amount,
periodAmount: getExpensePeriodAmount(amount, expense.recurrence, occurrences.length),
occurrenceCount: expense.recurrence === "MONTHLY" ? occurrences.length : 1,
occurrences,
budgetId: expense.budgetId,
periodId: expense.periodId,
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
recurrenceStartAt,
cutoffId: expenseCutoffById.get(expense.id)?.cutoff_id ?? primaryCutoffByPeriodId.get(expense.periodId)?.id ?? null,
cutoffPhase: expenseCutoffById.get(expense.id)?.cutoff_phase ?? "PRE",
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
donations: (donationRowsByExpenseId.get(expense.id) ?? []).map((donation) => ({
id: donation.id,
title: donation.title,
description: donation.description,
amount: Number(donation.amount),
donatedAt: donation.donated_at.toISOString()
})),
netPeriodAmount: Math.max(
0,
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -
(donationsByExpenseId.get(expense.id) ?? 0)
),
paidAt: expense.paidAt?.toISOString() ?? null,
documentedAt: expense.documentedAt?.toISOString() ?? null,
documents: expense.documents.map((document) => ({
id: document.id,
invoiceDate: document.invoiceDate.toISOString(),
proofUrl: document.proofUrl,
storedFileName: document.storedFileName,
originalFileName: document.originalFileName,
mimeType: document.mimeType,
size: document.size,
createdAt: document.createdAt.toISOString(),
uploadedBy: {
id: document.uploadedBy.id,
name: document.uploadedBy.username
}
})),
createdAt: expense.createdAt.toISOString(),
creator: {
id: expense.creator.id,
name: expense.creator.username
},
approvals: expense.approvals.map((approval) => ({
id: approval.id,
approvalType: approval.approvalType,
timestamp: approval.timestamp.toISOString(),
user: {
id: approval.user.id,
name: approval.user.username
}
}))
};
})
}))
}));
const serializedUsers: DashboardManagedUser[] = managedUsers.map((user) => ({
id: user.id,
name: user.username,
username: user.username,
role: user.role,
workingGroupId: user.workingGroupId,
workingGroupName: user.workingGroup?.name ?? null,
approvalPermissions: normalizeApprovalPermissions(user.role, user.approvalPermissions, user.approvalPreference),
createdExpensesCount: user._count.createdExpenses,
approvalsCount: user._count.approvals
}));
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: primaryCutoffByPeriodId.get(period.id)?.name ?? "Open Air",
cutoffDate: primaryCutoffByPeriodId.get(period.id)?.date?.toISOString() ?? null
}));
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({
id: donation.id,
title: donation.title,
description: donation.description,
amount: Number(donation.amount),
donatedAt: donation.donated_at.toISOString(),
periodId: donation.period_id,
expenseId: donation.expense_id,
workingGroupId: donation.working_group_id,
workingGroupName: donation.working_group_name,
expenseTitle: donation.expense_title,
createdAt: donation.created_at.toISOString(),
creator: {
id: donation.creator_id,
name: donation.creator_name
}
}));
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({
id: entry.id,
action: entry.action,
entityType: entry.entityType,
entityId: entry.entityId,
entityLabel: entry.entityLabel,
summary: entry.summary,
canRestore: Boolean(getRollbackMetadata(entry.metadata)),
createdAt: entry.createdAt.toISOString(),
actor: entry.actor
? {
id: entry.actor.id,
name: entry.actor.username,
username: entry.actor.username,
role: entry.actor.role
}
: null
}));
return (
<DashboardShell
viewer={serializedViewer}
workingGroups={serializedGroups}
managedUsers={serializedUsers}
auditLogs={serializedAuditLogs}
accountingPeriods={serializedPeriods}
currentPeriodId={currentPeriod.id}
settings={serializedSettings}
donations={serializedDonations}
/>
);
}