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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user