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:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "expenses" ADD COLUMN "recurrence_start_at" TIMESTAMP(3);
|
||||||
@@ -112,6 +112,7 @@ model Expense {
|
|||||||
periodId String @map("period_id")
|
periodId String @map("period_id")
|
||||||
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
|
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
|
||||||
recurrence ExpenseRecurrence @default(NONE)
|
recurrence ExpenseRecurrence @default(NONE)
|
||||||
|
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
documentedAt DateTime? @map("documented_at")
|
documentedAt DateTime? @map("documented_at")
|
||||||
proofUrl String? @map("proof_url")
|
proofUrl String? @map("proof_url")
|
||||||
|
|||||||
@@ -214,7 +214,8 @@ async function main() {
|
|||||||
periodId: currentPeriod.id,
|
periodId: currentPeriod.id,
|
||||||
approvalStatus: ApprovalStatus.APPROVED,
|
approvalStatus: ApprovalStatus.APPROVED,
|
||||||
proofUrl: null,
|
proofUrl: null,
|
||||||
recurrence: ExpenseRecurrence.MONTHLY
|
recurrence: ExpenseRecurrence.MONTHLY,
|
||||||
|
recurrenceStartAt: currentPeriod.startsAt
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
periodId: asString(deleted.periodId, "Zeitraum-ID"),
|
periodId: asString(deleted.periodId, "Zeitraum-ID"),
|
||||||
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
|
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
|
||||||
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
|
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
|
||||||
|
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
|
||||||
proofUrl: asNullableString(deleted.proofUrl),
|
proofUrl: asNullableString(deleted.proofUrl),
|
||||||
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
|
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
|
||||||
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
|
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
|
||||||
|
|||||||
@@ -8,19 +8,59 @@ import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
const expenseSchema = z.object({
|
function parseDateInput(value: string) {
|
||||||
title: z.string().trim().min(2).max(120),
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
description: z
|
|
||||||
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
if (!match) {
|
||||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
|
return null;
|
||||||
amount: z.coerce.number().positive(),
|
}
|
||||||
agId: z.string().trim().min(1),
|
|
||||||
budgetId: z.string().trim().min(1),
|
const [, year, month, day] = match;
|
||||||
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
|
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
|
||||||
proofUrl: z
|
|
||||||
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined))
|
}
|
||||||
});
|
|
||||||
|
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) {
|
export async function POST(request: Request) {
|
||||||
const viewer = await getCurrentViewer();
|
const viewer = await getCurrentViewer();
|
||||||
@@ -33,7 +73,10 @@ export async function POST(request: Request) {
|
|||||||
const parsed = expenseSchema.safeParse(body);
|
const parsed = expenseSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
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)) {
|
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) {
|
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 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({
|
const expense = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -64,6 +111,7 @@ export async function POST(request: Request) {
|
|||||||
creatorId: viewer.id,
|
creatorId: viewer.id,
|
||||||
proofUrl: parsed.data.proofUrl,
|
proofUrl: parsed.data.proofUrl,
|
||||||
recurrence: parsed.data.recurrence,
|
recurrence: parsed.data.recurrence,
|
||||||
|
recurrenceStartAt,
|
||||||
approvalStatus: requiresManualApproval(parsed.data.amount, approvalThreshold) ? "PENDING" : "APPROVED"
|
approvalStatus: requiresManualApproval(parsed.data.amount, approvalThreshold) ? "PENDING" : "APPROVED"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,6 +128,7 @@ export async function POST(request: Request) {
|
|||||||
budgetId: parsed.data.budgetId,
|
budgetId: parsed.data.budgetId,
|
||||||
workingGroupId: parsed.data.agId,
|
workingGroupId: parsed.data.agId,
|
||||||
recurrence: parsed.data.recurrence,
|
recurrence: parsed.data.recurrence,
|
||||||
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
rollback: {
|
rollback: {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const CSV_HEADERS = [
|
|||||||
"approvalStatus",
|
"approvalStatus",
|
||||||
"approvalType",
|
"approvalType",
|
||||||
"recurrence",
|
"recurrence",
|
||||||
|
"recurrenceStartAt",
|
||||||
"proofUrl",
|
"proofUrl",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"paidAt",
|
"paidAt",
|
||||||
@@ -201,6 +202,7 @@ export async function GET() {
|
|||||||
approvalStatus: "",
|
approvalStatus: "",
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
|
recurrenceStartAt: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -252,6 +254,7 @@ export async function GET() {
|
|||||||
approvalStatus: "",
|
approvalStatus: "",
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
|
recurrenceStartAt: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: period.createdAt.toISOString(),
|
createdAt: period.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -303,6 +306,7 @@ export async function GET() {
|
|||||||
approvalStatus: "",
|
approvalStatus: "",
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
|
recurrenceStartAt: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: group.createdAt.toISOString(),
|
createdAt: group.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -353,6 +357,7 @@ export async function GET() {
|
|||||||
approvalStatus: "",
|
approvalStatus: "",
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
|
recurrenceStartAt: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: budget.createdAt.toISOString(),
|
createdAt: budget.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -403,6 +408,7 @@ export async function GET() {
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||||
proofUrl: expense.proofUrl ?? "",
|
proofUrl: expense.proofUrl ?? "",
|
||||||
createdAt: expense.createdAt.toISOString(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? "",
|
paidAt: expense.paidAt?.toISOString() ?? "",
|
||||||
@@ -453,6 +459,7 @@ export async function GET() {
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
approvalType: approval.approvalType,
|
approvalType: approval.approvalType,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: approval.timestamp.toISOString(),
|
createdAt: approval.timestamp.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -507,6 +514,7 @@ export async function GET() {
|
|||||||
approvalStatus: "",
|
approvalStatus: "",
|
||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
|
recurrenceStartAt: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: auditLog.createdAt.toISOString(),
|
createdAt: auditLog.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export async function POST(request: Request) {
|
|||||||
periodId: row.periodId,
|
periodId: row.periodId,
|
||||||
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
|
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
|
||||||
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
|
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
|
||||||
|
recurrenceStartAt: toDate(row.recurrenceStartAt),
|
||||||
proofUrl: toNullable(row.proofUrl),
|
proofUrl: toNullable(row.proofUrl),
|
||||||
createdAt: toDate(row.createdAt) ?? new Date(),
|
createdAt: toDate(row.createdAt) ?? new Date(),
|
||||||
paidAt: toDate(row.paidAt),
|
paidAt: toDate(row.paidAt),
|
||||||
|
|||||||
103
src/app/page.tsx
103
src/app/page.tsx
@@ -13,6 +13,7 @@ import type {
|
|||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import { canManageUsers, normalizeApprovalPermissions } from "@/lib/domain";
|
import { canManageUsers, normalizeApprovalPermissions } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { buildRecurringOccurrences, getExpensePeriodAmount } from "@/lib/recurring-expenses";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -24,10 +25,7 @@ export default async function DashboardPage() {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentPeriod, appSettings] = await Promise.all([
|
const [currentPeriod, appSettings] = await Promise.all([getCurrentAccountingPeriod(), getAppSettings()]);
|
||||||
getCurrentAccountingPeriod(),
|
|
||||||
getAppSettings()
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!currentPeriod) {
|
if (!currentPeriod) {
|
||||||
throw new Error("Kein Abrechnungszeitraum gefunden.");
|
throw new Error("Kein Abrechnungszeitraum gefunden.");
|
||||||
@@ -106,14 +104,7 @@ export default async function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [{ role: "asc" }, { username: "asc" }]
|
||||||
{
|
|
||||||
role: "asc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
username: "asc"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -142,11 +133,7 @@ export default async function DashboardPage() {
|
|||||||
username: viewer.username,
|
username: viewer.username,
|
||||||
role: viewer.role,
|
role: viewer.role,
|
||||||
workingGroupId: viewer.workingGroupId,
|
workingGroupId: viewer.workingGroupId,
|
||||||
approvalPermissions: normalizeApprovalPermissions(
|
approvalPermissions: normalizeApprovalPermissions(viewer.role, viewer.approvalPermissions, viewer.approvalPreference)
|
||||||
viewer.role,
|
|
||||||
viewer.approvalPermissions,
|
|
||||||
viewer.approvalPreference
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
|
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
|
||||||
@@ -165,33 +152,55 @@ export default async function DashboardPage() {
|
|||||||
totalBudget: Number(budget.totalBudget),
|
totalBudget: Number(budget.totalBudget),
|
||||||
colorCode: budget.colorCode,
|
colorCode: budget.colorCode,
|
||||||
periodId: budget.periodId,
|
periodId: budget.periodId,
|
||||||
expenses: budget.expenses.map((expense) => ({
|
expenses: budget.expenses.map((expense) => {
|
||||||
id: expense.id,
|
const amount = Number(expense.amount);
|
||||||
title: expense.title,
|
const recurrenceStartAt =
|
||||||
description: expense.description,
|
expense.recurrence === "MONTHLY"
|
||||||
amount: Number(expense.amount),
|
? (expense.recurrenceStartAt ?? expense.createdAt).toISOString()
|
||||||
budgetId: expense.budgetId,
|
: null;
|
||||||
periodId: expense.periodId,
|
const occurrences =
|
||||||
approvalStatus: expense.approvalStatus,
|
expense.recurrence === "MONTHLY" && recurrenceStartAt
|
||||||
recurrence: expense.recurrence,
|
? buildRecurringOccurrences({
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
expenseId: expense.id,
|
||||||
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
amount,
|
||||||
proofUrl: expense.proofUrl,
|
recurrenceStartAt,
|
||||||
createdAt: expense.createdAt.toISOString(),
|
periodStartsAt: currentPeriod.startsAt,
|
||||||
creator: {
|
periodEndsAt: currentPeriod.endsAt
|
||||||
id: expense.creator.id,
|
})
|
||||||
name: expense.creator.username
|
: [];
|
||||||
},
|
|
||||||
approvals: expense.approvals.map((approval) => ({
|
return {
|
||||||
id: approval.id,
|
id: expense.id,
|
||||||
approvalType: approval.approvalType,
|
title: expense.title,
|
||||||
timestamp: approval.timestamp.toISOString(),
|
description: expense.description,
|
||||||
user: {
|
amount,
|
||||||
id: approval.user.id,
|
periodAmount: getExpensePeriodAmount(amount, expense.recurrence, occurrences.length),
|
||||||
name: approval.user.username
|
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,
|
role: user.role,
|
||||||
workingGroupId: user.workingGroupId,
|
workingGroupId: user.workingGroupId,
|
||||||
workingGroupName: user.workingGroup?.name ?? null,
|
workingGroupName: user.workingGroup?.name ?? null,
|
||||||
approvalPermissions: normalizeApprovalPermissions(
|
approvalPermissions: normalizeApprovalPermissions(user.role, user.approvalPermissions, user.approvalPreference),
|
||||||
user.role,
|
|
||||||
user.approvalPermissions,
|
|
||||||
user.approvalPreference
|
|
||||||
),
|
|
||||||
createdExpensesCount: user._count.createdExpenses,
|
createdExpensesCount: user._count.createdExpenses,
|
||||||
approvalsCount: user._count.approvals
|
approvalsCount: user._count.approvals
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
|
|||||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||||
import DoneAllRoundedIcon from "@mui/icons-material/DoneAllRounded";
|
import DoneAllRoundedIcon from "@mui/icons-material/DoneAllRounded";
|
||||||
import EditRoundedIcon from "@mui/icons-material/EditRounded";
|
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 EuroRoundedIcon from "@mui/icons-material/EuroRounded";
|
||||||
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
|
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
|
||||||
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
|
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
Chip,
|
||||||
|
Collapse,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
@@ -63,6 +66,10 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|||||||
currency: "EUR"
|
currency: "EUR"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium"
|
||||||
|
});
|
||||||
|
|
||||||
const wrappingChipSx = {
|
const wrappingChipSx = {
|
||||||
height: "auto",
|
height: "auto",
|
||||||
"& .MuiChip-label": {
|
"& .MuiChip-label": {
|
||||||
@@ -114,11 +121,11 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getApprovedSpend(expenses: 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[]) {
|
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({
|
export function BudgetColumn({
|
||||||
@@ -142,6 +149,7 @@ export function BudgetColumn({
|
|||||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
||||||
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const budgetCardWidth = 352;
|
const budgetCardWidth = 352;
|
||||||
const groupCardWidth = Math.min(
|
const groupCardWidth = Math.min(
|
||||||
@@ -349,7 +357,15 @@ export function BudgetColumn({
|
|||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
gap={2}
|
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) => {
|
{group.budgets.map((budget) => {
|
||||||
const draft = getDraft(budget);
|
const draft = getDraft(budget);
|
||||||
@@ -567,6 +583,8 @@ export function BudgetColumn({
|
|||||||
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
|
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
|
||||||
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
|
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
|
||||||
: [];
|
: [];
|
||||||
|
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
||||||
|
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -591,7 +609,11 @@ export function BudgetColumn({
|
|||||||
{expense.title}
|
{expense.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<StatusChips expense={expense} />
|
<StatusChips expense={expense} />
|
||||||
@@ -603,6 +625,66 @@ export function BudgetColumn({
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : 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) ? (
|
{requiresManualApproval(expense.amount, approvalThreshold) ? (
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
{APPROVAL_FLOW.map((approvalType) => {
|
{APPROVAL_FLOW.map((approvalType) => {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type ExpenseFormState = {
|
|||||||
agId: string;
|
agId: string;
|
||||||
budgetId: string;
|
budgetId: string;
|
||||||
recurrence: "NONE" | "MONTHLY";
|
recurrence: "NONE" | "MONTHLY";
|
||||||
|
recurrenceStartAt: string;
|
||||||
proofUrl: string;
|
proofUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ export function DashboardShell({
|
|||||||
agId: defaultEditableGroup?.id ?? "",
|
agId: defaultEditableGroup?.id ?? "",
|
||||||
budgetId: defaultBudget?.id ?? "",
|
budgetId: defaultBudget?.id ?? "",
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||||
proofUrl: ""
|
proofUrl: ""
|
||||||
});
|
});
|
||||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||||
@@ -411,7 +413,7 @@ export function DashboardShell({
|
|||||||
(groupSum, budget) =>
|
(groupSum, budget) =>
|
||||||
groupSum +
|
groupSum +
|
||||||
budget.expenses.reduce(
|
budget.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0),
|
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0),
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
@@ -420,7 +422,7 @@ export function DashboardShell({
|
|||||||
(groupSum, budget) =>
|
(groupSum, budget) =>
|
||||||
groupSum +
|
groupSum +
|
||||||
budget.expenses.reduce(
|
budget.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0),
|
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0),
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
@@ -485,7 +487,12 @@ export function DashboardShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!expenseForm.budgetId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +510,7 @@ export function DashboardShell({
|
|||||||
agId: expenseForm.agId,
|
agId: expenseForm.agId,
|
||||||
budgetId: expenseForm.budgetId,
|
budgetId: expenseForm.budgetId,
|
||||||
recurrence: expenseForm.recurrence,
|
recurrence: expenseForm.recurrence,
|
||||||
|
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
|
||||||
proofUrl: expenseForm.proofUrl
|
proofUrl: expenseForm.proofUrl
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -518,6 +526,7 @@ export function DashboardShell({
|
|||||||
agId: resetGroup,
|
agId: resetGroup,
|
||||||
budgetId: resetBudget,
|
budgetId: resetBudget,
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||||
proofUrl: ""
|
proofUrl: ""
|
||||||
});
|
});
|
||||||
}, "Ausgabe wurde gespeichert.");
|
}, "Ausgabe wurde gespeichert.");
|
||||||
@@ -1186,11 +1195,24 @@ export function DashboardShell({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
fullWidth
|
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="NONE">Einmalig</MenuItem>
|
||||||
<MenuItem value="MONTHLY">Monatliches Abo</MenuItem>
|
<MenuItem value="MONTHLY">Monatliches Abo</MenuItem>
|
||||||
</TextField>
|
</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
|
<TextField
|
||||||
select
|
select
|
||||||
label="Arbeitsgruppe"
|
label="Arbeitsgruppe"
|
||||||
@@ -1907,7 +1929,7 @@ export function DashboardShell({
|
|||||||
|
|
||||||
const overviewContent = (
|
const overviewContent = (
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
{visibleGroups.length > 1 ? (
|
{isCompactLayout && visibleGroups.length > 1 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
<CardContent sx={{ p: 2.5 }}>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
@@ -1916,7 +1938,7 @@ export function DashboardShell({
|
|||||||
AG auswählen
|
AG auswählen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1937,23 +1959,36 @@ export function DashboardShell({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack direction="column" gap={2}>
|
<Stack
|
||||||
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
|
direction={isCompactLayout ? "column" : "row"}
|
||||||
<BudgetColumn
|
gap={2}
|
||||||
key={group.id}
|
sx={{
|
||||||
group={group}
|
overflowX: isCompactLayout ? "visible" : "auto",
|
||||||
viewer={viewer}
|
overflowY: "hidden",
|
||||||
busy={busy}
|
pb: isCompactLayout ? 0 : 2,
|
||||||
approvalThreshold={approvalThreshold}
|
alignItems: "stretch",
|
||||||
onApprove={handleApprove}
|
scrollSnapType: isCompactLayout ? "none" : "x proximity",
|
||||||
onMarkPaid={handleMarkPaid}
|
scrollbarGutter: isCompactLayout ? "auto" : "stable both-edges",
|
||||||
onDocument={handleDocument}
|
overscrollBehaviorX: "contain"
|
||||||
onSaveWorkingGroup={handleSaveWorkingGroup}
|
}}
|
||||||
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
>
|
||||||
onSaveBudget={handleSaveBudget}
|
{(isCompactLayout ? (mobileSelectedGroup ? [mobileSelectedGroup] : []) : visibleGroups).map((group) => (
|
||||||
onDeleteBudget={handleDeleteBudget}
|
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||||
onDeleteExpense={handleDeleteExpense}
|
<BudgetColumn
|
||||||
/>
|
group={group}
|
||||||
|
viewer={viewer}
|
||||||
|
busy={busy}
|
||||||
|
approvalThreshold={approvalThreshold}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onMarkPaid={handleMarkPaid}
|
||||||
|
onDocument={handleDocument}
|
||||||
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
||||||
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
||||||
|
onSaveBudget={handleSaveBudget}
|
||||||
|
onDeleteBudget={handleDeleteBudget}
|
||||||
|
onDeleteExpense={handleDeleteExpense}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function snapshotExpense(
|
|||||||
| "periodId"
|
| "periodId"
|
||||||
| "approvalStatus"
|
| "approvalStatus"
|
||||||
| "recurrence"
|
| "recurrence"
|
||||||
|
| "recurrenceStartAt"
|
||||||
| "proofUrl"
|
| "proofUrl"
|
||||||
| "createdAt"
|
| "createdAt"
|
||||||
| "paidAt"
|
| "paidAt"
|
||||||
@@ -69,6 +70,7 @@ export function snapshotExpense(
|
|||||||
periodId: expense.periodId,
|
periodId: expense.periodId,
|
||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
proofUrl: expense.proofUrl,
|
proofUrl: expense.proofUrl,
|
||||||
createdAt: expense.createdAt.toISOString(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
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 = {
|
export type DashboardExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
periodAmount: number;
|
||||||
|
occurrenceCount: number;
|
||||||
|
occurrences: DashboardExpenseOccurrence[];
|
||||||
budgetId: string;
|
budgetId: string;
|
||||||
periodId: string;
|
periodId: string;
|
||||||
approvalStatus: ApprovalStatusValue;
|
approvalStatus: ApprovalStatusValue;
|
||||||
recurrence: ExpenseRecurrenceValue;
|
recurrence: ExpenseRecurrenceValue;
|
||||||
|
recurrenceStartAt: string | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
documentedAt: string | null;
|
documentedAt: string | null;
|
||||||
proofUrl: 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