Desktop ist wieder auf Horizontal-Scroll zurückgebaut, mobil bleibt die Dropdown-Auswahl. Dabei habe ich die Scroll-Container stabilisiert, damit die AG- und Budgetkarten sauber scrollen statt seitlich zu „wackeln“, in dashboard-shell.tsx und budget-column.tsx.
CI / Build (push) Successful in 1m17s
CI / Deploy (push) Successful in 1m0s

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.
This commit is contained in:
Jan
2026-04-13 13:53:20 +02:00
parent 700e677c45
commit ee8b1a6f7b
13 changed files with 379 additions and 92 deletions
@@ -478,6 +478,7 @@ export async function POST(_: Request, { params }: Context) {
periodId: asString(deleted.periodId, "Zeitraum-ID"),
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
proofUrl: asNullableString(deleted.proofUrl),
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
+64 -15
View File
@@ -8,19 +8,59 @@ import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
const expenseSchema = 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(),
agId: z.string().trim().min(1),
budgetId: z.string().trim().min(1),
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined))
});
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 expenseSchema = 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(),
agId: z.string().trim().min(1),
budgetId: z.string().trim().min(1),
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
recurrenceStartAt: z
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
.transform((value) => {
if (typeof value !== "string" || value.length === 0) {
return undefined;
}
return parseDateInput(value) ?? "invalid";
}),
proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined))
})
.superRefine((value, ctx) => {
if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bitte ein Startdatum für das monatliche Abo angeben.",
path: ["recurrenceStartAt"]
});
}
if (value.recurrenceStartAt === "invalid") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Das Abo-Startdatum ist ungültig.",
path: ["recurrenceStartAt"]
});
}
});
export async function POST(request: Request) {
const viewer = await getCurrentViewer();
@@ -33,7 +73,10 @@ export async function POST(request: Request) {
const parsed = expenseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Bitte Titel, Betrag und AG korrekt ausfüllen." }, { status: 400 });
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Bitte Titel, Betrag und AG korrekt ausfüllen." },
{ status: 400 }
);
}
if (!canCreateExpenseForGroup(viewer.role, viewer.workingGroupId, parsed.data.agId)) {
@@ -48,10 +91,14 @@ export async function POST(request: Request) {
]);
if (!budget || budget.workingGroupId !== parsed.data.agId) {
return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 });
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG." }, { status: 404 });
}
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
const recurrenceStartAt =
parsed.data.recurrence === "MONTHLY" && parsed.data.recurrenceStartAt instanceof Date
? parsed.data.recurrenceStartAt
: null;
const expense = await prisma.expense.create({
data: {
@@ -64,6 +111,7 @@ export async function POST(request: Request) {
creatorId: viewer.id,
proofUrl: parsed.data.proofUrl,
recurrence: parsed.data.recurrence,
recurrenceStartAt,
approvalStatus: requiresManualApproval(parsed.data.amount, approvalThreshold) ? "PENDING" : "APPROVED"
}
});
@@ -80,6 +128,7 @@ export async function POST(request: Request) {
budgetId: parsed.data.budgetId,
workingGroupId: parsed.data.agId,
recurrence: parsed.data.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
approvalStatus: expense.approvalStatus,
approvalThreshold,
rollback: {
+8
View File
@@ -37,6 +37,7 @@ const CSV_HEADERS = [
"approvalStatus",
"approvalType",
"recurrence",
"recurrenceStartAt",
"proofUrl",
"createdAt",
"paidAt",
@@ -201,6 +202,7 @@ export async function GET() {
approvalStatus: "",
approvalType: "",
recurrence: "",
recurrenceStartAt: "",
proofUrl: "",
createdAt: user.createdAt.toISOString(),
paidAt: "",
@@ -252,6 +254,7 @@ export async function GET() {
approvalStatus: "",
approvalType: "",
recurrence: "",
recurrenceStartAt: "",
proofUrl: "",
createdAt: period.createdAt.toISOString(),
paidAt: "",
@@ -303,6 +306,7 @@ export async function GET() {
approvalStatus: "",
approvalType: "",
recurrence: "",
recurrenceStartAt: "",
proofUrl: "",
createdAt: group.createdAt.toISOString(),
paidAt: "",
@@ -353,6 +357,7 @@ export async function GET() {
approvalStatus: "",
approvalType: "",
recurrence: "",
recurrenceStartAt: "",
proofUrl: "",
createdAt: budget.createdAt.toISOString(),
paidAt: "",
@@ -403,6 +408,7 @@ export async function GET() {
approvalStatus: expense.approvalStatus,
approvalType: "",
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
proofUrl: expense.proofUrl ?? "",
createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? "",
@@ -453,6 +459,7 @@ export async function GET() {
approvalStatus: expense.approvalStatus,
approvalType: approval.approvalType,
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
proofUrl: "",
createdAt: approval.timestamp.toISOString(),
paidAt: "",
@@ -507,6 +514,7 @@ export async function GET() {
approvalStatus: "",
approvalType: "",
recurrence: "",
recurrenceStartAt: "",
proofUrl: "",
createdAt: auditLog.createdAt.toISOString(),
paidAt: "",
+1
View File
@@ -204,6 +204,7 @@ export async function POST(request: Request) {
periodId: row.periodId,
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
recurrenceStartAt: toDate(row.recurrenceStartAt),
proofUrl: toNullable(row.proofUrl),
createdAt: toDate(row.createdAt) ?? new Date(),
paidAt: toDate(row.paidAt),
+54 -49
View File
@@ -13,6 +13,7 @@ import type {
} 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";
@@ -24,10 +25,7 @@ export default async function DashboardPage() {
redirect("/login");
}
const [currentPeriod, appSettings] = await Promise.all([
getCurrentAccountingPeriod(),
getAppSettings()
]);
const [currentPeriod, appSettings] = await Promise.all([getCurrentAccountingPeriod(), getAppSettings()]);
if (!currentPeriod) {
throw new Error("Kein Abrechnungszeitraum gefunden.");
@@ -106,14 +104,7 @@ export default async function DashboardPage() {
}
}
},
orderBy: [
{
role: "asc"
},
{
username: "asc"
}
]
orderBy: [{ role: "asc" }, { username: "asc" }]
})
: [];
@@ -142,11 +133,7 @@ export default async function DashboardPage() {
username: viewer.username,
role: viewer.role,
workingGroupId: viewer.workingGroupId,
approvalPermissions: normalizeApprovalPermissions(
viewer.role,
viewer.approvalPermissions,
viewer.approvalPreference
)
approvalPermissions: normalizeApprovalPermissions(viewer.role, viewer.approvalPermissions, viewer.approvalPreference)
};
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
@@ -165,33 +152,55 @@ export default async function DashboardPage() {
totalBudget: Number(budget.totalBudget),
colorCode: budget.colorCode,
periodId: budget.periodId,
expenses: budget.expenses.map((expense) => ({
id: expense.id,
title: expense.title,
description: expense.description,
amount: Number(expense.amount),
budgetId: expense.budgetId,
periodId: expense.periodId,
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
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
}
}))
}))
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
}
}))
};
})
}))
}));
@@ -202,11 +211,7 @@ export default async function DashboardPage() {
role: user.role,
workingGroupId: user.workingGroupId,
workingGroupName: user.workingGroup?.name ?? null,
approvalPermissions: normalizeApprovalPermissions(
user.role,
user.approvalPermissions,
user.approvalPreference
),
approvalPermissions: normalizeApprovalPermissions(user.role, user.approvalPermissions, user.approvalPreference),
createdExpensesCount: user._count.createdExpenses,
approvalsCount: user._count.approvals
}));