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.
All checks were successful
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

View File

@@ -52,6 +52,7 @@ export function snapshotExpense(
| "periodId"
| "approvalStatus"
| "recurrence"
| "recurrenceStartAt"
| "proofUrl"
| "createdAt"
| "paidAt"
@@ -69,6 +70,7 @@ export function snapshotExpense(
periodId: expense.periodId,
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
proofUrl: expense.proofUrl,
createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? null,

View File

@@ -27,15 +27,26 @@ export type DashboardApproval = {
};
};
export type DashboardExpenseOccurrence = {
id: string;
label: string;
dueAt: string;
amount: number;
};
export type DashboardExpense = {
id: string;
title: string;
description: string | null;
amount: number;
periodAmount: number;
occurrenceCount: number;
occurrences: DashboardExpenseOccurrence[];
budgetId: string;
periodId: string;
approvalStatus: ApprovalStatusValue;
recurrence: ExpenseRecurrenceValue;
recurrenceStartAt: string | null;
paidAt: string | null;
documentedAt: string | null;
proofUrl: string | null;

View File

@@ -0,0 +1,90 @@
export type RecurringOccurrence = {
id: string;
label: string;
dueAt: string;
amount: number;
};
const monthLabelFormatter = new Intl.DateTimeFormat("de-DE", {
month: "long",
year: "numeric"
});
function toDate(value: string | Date) {
return value instanceof Date ? value : new Date(value);
}
function startOfUtcDay(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0));
}
function endOfUtcDay(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999));
}
function startOfUtcMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
}
function addUtcMonths(date: Date, months: number) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
}
function daysInUtcMonth(year: number, monthIndex: number) {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
function buildDueDate(monthCursor: Date, recurrenceStartAt: Date) {
const year = monthCursor.getUTCFullYear();
const monthIndex = monthCursor.getUTCMonth();
const day = Math.min(recurrenceStartAt.getUTCDate(), daysInUtcMonth(year, monthIndex));
return new Date(Date.UTC(year, monthIndex, day, 12, 0, 0, 0));
}
export function buildRecurringOccurrences({
expenseId,
amount,
recurrenceStartAt,
periodStartsAt,
periodEndsAt
}: {
expenseId: string;
amount: number;
recurrenceStartAt: string | Date;
periodStartsAt: string | Date;
periodEndsAt: string | Date;
}) {
const seriesStart = startOfUtcDay(toDate(recurrenceStartAt));
const periodStart = startOfUtcDay(toDate(periodStartsAt));
const periodEnd = endOfUtcDay(toDate(periodEndsAt));
if (seriesStart > periodEnd) {
return [] as RecurringOccurrence[];
}
const occurrences: RecurringOccurrence[] = [];
let cursor = startOfUtcMonth(seriesStart > periodStart ? seriesStart : periodStart);
const lastMonth = startOfUtcMonth(periodEnd);
while (cursor <= lastMonth) {
const dueAt = buildDueDate(cursor, seriesStart);
if (dueAt >= seriesStart && dueAt >= periodStart && dueAt <= periodEnd) {
occurrences.push({
id: `${expenseId}-${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, "0")}`,
label: monthLabelFormatter.format(dueAt),
dueAt: dueAt.toISOString(),
amount
});
}
cursor = addUtcMonths(cursor, 1);
}
return occurrences;
}
export function getExpensePeriodAmount(amount: number, recurrence: "NONE" | "MONTHLY", occurrenceCount: number) {
return recurrence === "MONTHLY" ? amount * occurrenceCount : amount;
}