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

@@ -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) => {