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")
|
||||
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")
|
||||
|
||||
@@ -214,7 +214,8 @@ async function main() {
|
||||
periodId: currentPeriod.id,
|
||||
approvalStatus: ApprovalStatus.APPROVED,
|
||||
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"),
|
||||
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"),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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),
|
||||
|
||||
103
src/app/page.tsx
103
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
|
||||
}));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,23 +1959,36 @@ export function DashboardShell({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Stack direction="column" gap={2}>
|
||||
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
|
||||
<BudgetColumn
|
||||
key={group.id}
|
||||
group={group}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
onApprove={handleApprove}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
onDocument={handleDocument}
|
||||
onSaveWorkingGroup={handleSaveWorkingGroup}
|
||||
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
||||
onSaveBudget={handleSaveBudget}
|
||||
onDeleteBudget={handleDeleteBudget}
|
||||
onDeleteExpense={handleDeleteExpense}
|
||||
/>
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function snapshotExpense(
|
||||
| "periodId"
|
||||
| "approvalStatus"
|
||||
| "recurrence"
|
||||
| "recurrenceStartAt"
|
||||
| "proofUrl"
|
||||
| "createdAt"
|
||||
| "paidAt"
|
||||
@@ -69,6 +70,7 @@ export function snapshotExpense(
|
||||
periodId: expense.periodId,
|
||||
approvalStatus: expense.approvalStatus,
|
||||
recurrence: expense.recurrence,
|
||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||
proofUrl: expense.proofUrl,
|
||||
createdAt: expense.createdAt.toISOString(),
|
||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||
|
||||
@@ -27,15 +27,26 @@ export type DashboardApproval = {
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardExpenseOccurrence = {
|
||||
id: string;
|
||||
label: string;
|
||||
dueAt: string;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type DashboardExpense = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: number;
|
||||
periodAmount: number;
|
||||
occurrenceCount: number;
|
||||
occurrences: DashboardExpenseOccurrence[];
|
||||
budgetId: string;
|
||||
periodId: string;
|
||||
approvalStatus: ApprovalStatusValue;
|
||||
recurrence: ExpenseRecurrenceValue;
|
||||
recurrenceStartAt: string | null;
|
||||
paidAt: string | null;
|
||||
documentedAt: string | null;
|
||||
proofUrl: string | null;
|
||||
|
||||
90
src/lib/recurring-expenses.ts
Normal file
90
src/lib/recurring-expenses.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type RecurringOccurrence = {
|
||||
id: string;
|
||||
label: string;
|
||||
dueAt: string;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
const monthLabelFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
});
|
||||
|
||||
function toDate(value: string | Date) {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function endOfUtcDay(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999));
|
||||
}
|
||||
|
||||
function startOfUtcMonth(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function addUtcMonths(date: Date, months: number) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function daysInUtcMonth(year: number, monthIndex: number) {
|
||||
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function buildDueDate(monthCursor: Date, recurrenceStartAt: Date) {
|
||||
const year = monthCursor.getUTCFullYear();
|
||||
const monthIndex = monthCursor.getUTCMonth();
|
||||
const day = Math.min(recurrenceStartAt.getUTCDate(), daysInUtcMonth(year, monthIndex));
|
||||
|
||||
return new Date(Date.UTC(year, monthIndex, day, 12, 0, 0, 0));
|
||||
}
|
||||
|
||||
export function buildRecurringOccurrences({
|
||||
expenseId,
|
||||
amount,
|
||||
recurrenceStartAt,
|
||||
periodStartsAt,
|
||||
periodEndsAt
|
||||
}: {
|
||||
expenseId: string;
|
||||
amount: number;
|
||||
recurrenceStartAt: string | Date;
|
||||
periodStartsAt: string | Date;
|
||||
periodEndsAt: string | Date;
|
||||
}) {
|
||||
const seriesStart = startOfUtcDay(toDate(recurrenceStartAt));
|
||||
const periodStart = startOfUtcDay(toDate(periodStartsAt));
|
||||
const periodEnd = endOfUtcDay(toDate(periodEndsAt));
|
||||
|
||||
if (seriesStart > periodEnd) {
|
||||
return [] as RecurringOccurrence[];
|
||||
}
|
||||
|
||||
const occurrences: RecurringOccurrence[] = [];
|
||||
let cursor = startOfUtcMonth(seriesStart > periodStart ? seriesStart : periodStart);
|
||||
const lastMonth = startOfUtcMonth(periodEnd);
|
||||
|
||||
while (cursor <= lastMonth) {
|
||||
const dueAt = buildDueDate(cursor, seriesStart);
|
||||
|
||||
if (dueAt >= seriesStart && dueAt >= periodStart && dueAt <= periodEnd) {
|
||||
occurrences.push({
|
||||
id: `${expenseId}-${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, "0")}`,
|
||||
label: monthLabelFormatter.format(dueAt),
|
||||
dueAt: dueAt.toISOString(),
|
||||
amount
|
||||
});
|
||||
}
|
||||
|
||||
cursor = addUtcMonths(cursor, 1);
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
export function getExpensePeriodAmount(amount: number, recurrence: "NONE" | "MONTHLY", occurrenceCount: number) {
|
||||
return recurrence === "MONTHLY" ? amount * occurrenceCount : amount;
|
||||
}
|
||||
Reference in New Issue
Block a user