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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
90
src/lib/recurring-expenses.ts
Normal file
90
src/lib/recurring-expenses.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user