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

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