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.
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:
@@ -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"),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user