391 lines
13 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|