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