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

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