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

@@ -0,0 +1 @@
ALTER TABLE "expenses" ADD COLUMN "recurrence_start_at" TIMESTAMP(3);

View File

@@ -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")

View File

@@ -214,7 +214,8 @@ async function main() {
periodId: currentPeriod.id,
approvalStatus: ApprovalStatus.APPROVED,
proofUrl: null,
recurrence: ExpenseRecurrence.MONTHLY
recurrence: ExpenseRecurrence.MONTHLY,
recurrenceStartAt: currentPeriod.startsAt
}
});
}

View File

@@ -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"),

View File

@@ -8,7 +8,21 @@ import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
const expenseSchema = z.object({
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()])
@@ -17,9 +31,35 @@ const expenseSchema = z.object({
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) {
@@ -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: {

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: "",

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),

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,15 +152,36 @@ export default async function DashboardPage() {
totalBudget: Number(budget.totalBudget),
colorCode: budget.colorCode,
periodId: budget.periodId,
expenses: budget.expenses.map((expense) => ({
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: Number(expense.amount),
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,
@@ -191,7 +199,8 @@ export default async function DashboardPage() {
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
}));

View File

@@ -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<Record<string, string>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const budgetCardWidth = 352;
const groupCardWidth = Math.min(
@@ -349,7 +357,15 @@ export function BudgetColumn({
<Stack
direction="row"
gap={2}
sx={{ overflowX: "auto", pb: 1.5, alignItems: "stretch", scrollSnapType: "x proximity" }}
sx={{
overflowX: "auto",
overflowY: "hidden",
pb: 1.5,
alignItems: "stretch",
scrollSnapType: "x proximity",
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain"
}}
>
{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 (
<Box
@@ -591,7 +609,11 @@ export function BudgetColumn({
{expense.title}
</Typography>
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
{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}`}
</Typography>
</Box>
<StatusChips expense={expense} />
@@ -603,6 +625,66 @@ export function BudgetColumn({
</Typography>
) : null}
{isRecurringSeries ? (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{`Abo-Start: ${dateFormatter.format(new Date(expense.recurrenceStartAt ?? expense.createdAt))}`}
</Typography>
{expense.occurrenceCount > 0 ? (
<>
<Button
type="button"
size="small"
variant="text"
onClick={() =>
setExpandedRecurringExpenses((current) => ({
...current,
[expense.id]: !isRecurringExpanded
}))
}
endIcon={isRecurringExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
sx={{ alignSelf: "flex-start", px: 0 }}
>
{isRecurringExpanded
? "Monatsraten ausblenden"
: `Monatsraten anzeigen (${expense.occurrenceCount})`}
</Button>
<Collapse in={isRecurringExpanded} unmountOnExit>
<Stack spacing={0.8}>
{expense.occurrences.map((occurrence) => (
<Box
key={occurrence.id}
sx={{
p: 1.2,
borderRadius: "14px",
backgroundColor: alpha(budget.colorCode, isDark ? 0.12 : 0.06)
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
gap={0.75}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{occurrence.label}
</Typography>
<Typography variant="body2" color="text.secondary">
{formatCurrency(occurrence.amount)} · fällig {dateFormatter.format(new Date(occurrence.dueAt))}
</Typography>
</Stack>
</Box>
))}
</Stack>
</Collapse>
</>
) : (
<Typography variant="body2" color="text.secondary">
In diesem Zeitraum fällt noch keine Monatsrate an.
</Typography>
)}
</Stack>
) : null}
{requiresManualApproval(expense.amount, approvalThreshold) ? (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => {

View File

@@ -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<BudgetFormState>({
@@ -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."}
>
<MenuItem value="NONE">Einmalig</MenuItem>
<MenuItem value="MONTHLY">Monatliches Abo</MenuItem>
</TextField>
{expenseForm.recurrence === "MONTHLY" ? (
<TextField
label="Abo-Startdatum"
type="date"
value={expenseForm.recurrenceStartAt}
onChange={(event) =>
setExpenseForm((current) => ({ ...current, recurrenceStartAt: event.target.value }))
}
InputLabelProps={{ shrink: true }}
fullWidth
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
/>
) : null}
<TextField
select
label="Arbeitsgruppe"
@@ -1907,7 +1929,7 @@ export function DashboardShell({
const overviewContent = (
<Stack spacing={2.5}>
{visibleGroups.length > 1 ? (
{isCompactLayout && visibleGroups.length > 1 ? (
<Card>
<CardContent sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
@@ -1916,7 +1938,7 @@ export function DashboardShell({
AG auswählen
</Typography>
<Typography color="text.secondary">
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.
</Typography>
</Box>
<TextField
@@ -1937,10 +1959,22 @@ export function DashboardShell({
</Card>
) : null}
<Stack direction="column" gap={2}>
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
<Stack
direction={isCompactLayout ? "column" : "row"}
gap={2}
sx={{
overflowX: isCompactLayout ? "visible" : "auto",
overflowY: "hidden",
pb: isCompactLayout ? 0 : 2,
alignItems: "stretch",
scrollSnapType: isCompactLayout ? "none" : "x proximity",
scrollbarGutter: isCompactLayout ? "auto" : "stable both-edges",
overscrollBehaviorX: "contain"
}}
>
{(isCompactLayout ? (mobileSelectedGroup ? [mobileSelectedGroup] : []) : visibleGroups).map((group) => (
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
key={group.id}
group={group}
viewer={viewer}
busy={busy}
@@ -1954,6 +1988,7 @@ export function DashboardShell({
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
/>
</Box>
))}
</Stack>
</Stack>

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;
}