Die Abo-Logik ist jetzt deutlich sauberer: beim Anlegen gibt es ein Startdatum, der Server leitet daraus Monatsraten für den gewählten Zeitraum ab, Budgets rechnen mit dem periodischen Gesamtbetrag, und Abo-Ausgaben erscheinen als aufklappbare Gruppe statt als aufgeblähte Liste. Das steckt vor allem in page.tsx, recurring-expenses.ts, route.ts, dashboard-types.ts und der Migration migration.sql. Backup/Import und Audit-Restore kennen das neue Feld ebenfalls.
258 lines
7.4 KiB
TypeScript
258 lines
7.4 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
|
|
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
|
import { getCurrentAccountingPeriod } from "@/lib/accounting-periods";
|
|
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
|
|
import { getRollbackMetadata } from "@/lib/audit-log";
|
|
import type {
|
|
DashboardAccountingPeriod,
|
|
DashboardAuditLog,
|
|
DashboardManagedUser,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
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 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),
|
|
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,
|
|
paidAt: expense.paidAt?.toISOString() ?? null,
|
|
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
|
proofUrl: expense.proofUrl,
|
|
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) => ({
|
|
id: period.id,
|
|
name: period.name,
|
|
startsAt: period.startsAt.toISOString(),
|
|
endsAt: period.endsAt.toISOString(),
|
|
isCurrent: period.isCurrent
|
|
}));
|
|
|
|
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}
|
|
approvalThreshold={toApprovalThresholdNumber(appSettings.approvalThreshold)}
|
|
/>
|
|
);
|
|
}
|