From ee8b1a6f7b9f097fe3de9ce1ded233aa21ae334c Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 13 Apr 2026 13:53:20 +0200 Subject: [PATCH] =?UTF-8?q?Desktop=20ist=20wieder=20auf=20Horizontal-Scrol?= =?UTF-8?q?l=20zur=C3=BCckgebaut,=20mobil=20bleibt=20die=20Dropdown-Auswah?= =?UTF-8?q?l.=20Dabei=20habe=20ich=20die=20Scroll-Container=20stabilisiert?= =?UTF-8?q?,=20damit=20die=20AG-=20und=20Budgetkarten=20sauber=20scrollen?= =?UTF-8?q?=20statt=20seitlich=20zu=20=E2=80=9Ewackeln=E2=80=9C,=20in=20da?= =?UTF-8?q?shboard-shell.tsx=20und=20budget-column.tsx.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migration.sql | 1 + prisma/schema.prisma | 1 + prisma/seed.ts | 3 +- src/app/api/audit-logs/[id]/restore/route.ts | 1 + src/app/api/expenses/route.ts | 79 +++++++++++--- src/app/api/export/csv/route.ts | 8 ++ src/app/api/import/csv/route.ts | 1 + src/app/page.tsx | 103 +++++++++--------- src/components/dashboard/budget-column.tsx | 90 ++++++++++++++- src/components/dashboard/dashboard-shell.tsx | 81 ++++++++++---- src/lib/audit-snapshots.ts | 2 + src/lib/dashboard-types.ts | 11 ++ src/lib/recurring-expenses.ts | 90 +++++++++++++++ 13 files changed, 379 insertions(+), 92 deletions(-) create mode 100644 prisma/migrations/202604131820_recurring_subscription_start/migration.sql create mode 100644 src/lib/recurring-expenses.ts diff --git a/prisma/migrations/202604131820_recurring_subscription_start/migration.sql b/prisma/migrations/202604131820_recurring_subscription_start/migration.sql new file mode 100644 index 0000000..4ba2288 --- /dev/null +++ b/prisma/migrations/202604131820_recurring_subscription_start/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "expenses" ADD COLUMN "recurrence_start_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2174341..d8b031f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,6 +112,7 @@ model Expense { periodId String @map("period_id") approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") recurrence ExpenseRecurrence @default(NONE) + recurrenceStartAt DateTime? @map("recurrence_start_at") paidAt DateTime? @map("paid_at") documentedAt DateTime? @map("documented_at") proofUrl String? @map("proof_url") diff --git a/prisma/seed.ts b/prisma/seed.ts index 744d717..6499ed8 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -214,7 +214,8 @@ async function main() { periodId: currentPeriod.id, approvalStatus: ApprovalStatus.APPROVED, proofUrl: null, - recurrence: ExpenseRecurrence.MONTHLY + recurrence: ExpenseRecurrence.MONTHLY, + recurrenceStartAt: currentPeriod.startsAt } }); } diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index 4ea62b7..67dacc2 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -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"), diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index 3321307..6b7e980 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -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: { diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index 4e79b72..f1945bd 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -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: "", diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index b63e791..16dcfac 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -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), diff --git a/src/app/page.tsx b/src/app/page.tsx index edb6d10..4e764dd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 })); diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 38c3571..8cceafa 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -5,6 +5,8 @@ import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import DoneAllRoundedIcon from "@mui/icons-material/DoneAllRounded"; import EditRoundedIcon from "@mui/icons-material/EditRounded"; +import ExpandLessRoundedIcon from "@mui/icons-material/ExpandLessRounded"; +import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; import EuroRoundedIcon from "@mui/icons-material/EuroRounded"; import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded"; import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded"; @@ -14,6 +16,7 @@ import { Card, CardContent, Chip, + Collapse, Divider, IconButton, Link, @@ -63,6 +66,10 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", { currency: "EUR" }); +const dateFormatter = new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium" +}); + const wrappingChipSx = { height: "auto", "& .MuiChip-label": { @@ -114,11 +121,11 @@ function StatusChips({ expense }: { expense: DashboardExpense }) { } function getApprovedSpend(expenses: DashboardExpense[]) { - return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0), 0); + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), 0); } function getPendingSpend(expenses: DashboardExpense[]) { - return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), 0); + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0); } export function BudgetColumn({ @@ -142,6 +149,7 @@ export function BudgetColumn({ const [isEditingGroup, setIsEditingGroup] = useState(false); const [groupDraftName, setGroupDraftName] = useState(group.name); const [proofUrlDrafts, setProofUrlDrafts] = useState>({}); + const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); const budgetCardWidth = 352; const groupCardWidth = Math.min( @@ -349,7 +357,15 @@ export function BudgetColumn({ {group.budgets.map((budget) => { const draft = getDraft(budget); @@ -567,6 +583,8 @@ export function BudgetColumn({ const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold) ? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes) : []; + const isRecurringSeries = expense.recurrence === "MONTHLY"; + const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false; return ( - {formatCurrency(expense.amount)} von {expense.creator.name} + {isRecurringSeries + ? expense.occurrenceCount > 0 + ? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}` + : `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}` + : `${formatCurrency(expense.amount)} von ${expense.creator.name}`} @@ -603,6 +625,66 @@ export function BudgetColumn({ ) : null} + {isRecurringSeries ? ( + + + {`Abo-Start: ${dateFormatter.format(new Date(expense.recurrenceStartAt ?? expense.createdAt))}`} + + {expense.occurrenceCount > 0 ? ( + <> + + + + {expense.occurrences.map((occurrence) => ( + + + + {occurrence.label} + + + {formatCurrency(occurrence.amount)} · fällig {dateFormatter.format(new Date(occurrence.dueAt))} + + + + ))} + + + + ) : ( + + In diesem Zeitraum fällt noch keine Monatsrate an. + + )} + + ) : null} + {requiresManualApproval(expense.amount, approvalThreshold) ? ( {APPROVAL_FLOW.map((approvalType) => { diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index de81702..460c91a 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -66,6 +66,7 @@ type ExpenseFormState = { agId: string; budgetId: string; recurrence: "NONE" | "MONTHLY"; + recurrenceStartAt: string; proofUrl: string; }; @@ -233,6 +234,7 @@ export function DashboardShell({ agId: defaultEditableGroup?.id ?? "", budgetId: defaultBudget?.id ?? "", recurrence: "NONE", + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), proofUrl: "" }); const [budgetForm, setBudgetForm] = useState({ @@ -411,7 +413,7 @@ export function DashboardShell({ (groupSum, budget) => groupSum + budget.expenses.reduce( - (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0), + (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), 0 ), 0 @@ -420,7 +422,7 @@ export function DashboardShell({ (groupSum, budget) => groupSum + budget.expenses.reduce( - (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), + (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0 ), 0 @@ -485,7 +487,12 @@ export function DashboardShell({ } if (!expenseForm.budgetId) { - setMessage({ type: "error", text: "Bitte zuerst ein Budget f\u00fcr diese AG anlegen oder ausw\u00e4hlen." }); + setMessage({ type: "error", text: "Bitte zuerst ein Budget für diese AG anlegen oder auswählen." }); + return; + } + + if (expenseForm.recurrence === "MONTHLY" && !expenseForm.recurrenceStartAt) { + setMessage({ type: "error", text: "Bitte ein Startdatum für das monatliche Abo angeben." }); return; } @@ -503,6 +510,7 @@ export function DashboardShell({ agId: expenseForm.agId, budgetId: expenseForm.budgetId, recurrence: expenseForm.recurrence, + recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "", proofUrl: expenseForm.proofUrl }) }) @@ -518,6 +526,7 @@ export function DashboardShell({ agId: resetGroup, budgetId: resetBudget, recurrence: "NONE", + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), proofUrl: "" }); }, "Ausgabe wurde gespeichert."); @@ -1186,11 +1195,24 @@ export function DashboardShell({ })) } fullWidth - helperText={"Monatliche Abos erscheinen oben gesammelt im \u00dcberblick."} + helperText={"Monatliche Abos werden im Zeitraum automatisch Monat für Monat fortgeschrieben."} > Einmalig Monatliches Abo + {expenseForm.recurrence === "MONTHLY" ? ( + + setExpenseForm((current) => ({ ...current, recurrenceStartAt: event.target.value })) + } + InputLabelProps={{ shrink: true }} + fullWidth + helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."} + /> + ) : null} - {visibleGroups.length > 1 ? ( + {isCompactLayout && visibleGroups.length > 1 ? ( @@ -1916,7 +1938,7 @@ export function DashboardShell({ AG auswählen - Wähle die AG, die gerade in der Übersicht angezeigt werden soll. + Mobil zeigen wir jeweils eine AG auf einmal, damit die Budgetkarten sauber lesbar bleiben. ) : null} - - {(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => ( - + + {(isCompactLayout ? (mobileSelectedGroup ? [mobileSelectedGroup] : []) : visibleGroups).map((group) => ( + + + ))} diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts index 9af68cb..d2e6bc2 100644 --- a/src/lib/audit-snapshots.ts +++ b/src/lib/audit-snapshots.ts @@ -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, diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index 7be27ef..213c411 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -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; diff --git a/src/lib/recurring-expenses.ts b/src/lib/recurring-expenses.ts new file mode 100644 index 0000000..8293109 --- /dev/null +++ b/src/lib/recurring-expenses.ts @@ -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; +}