Ausgaben und Spenden bearbeitbar machen
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s

This commit is contained in:
jan
2026-05-12 00:47:07 +02:00
parent c738b35d06
commit a527a840ee
6 changed files with 881 additions and 80 deletions

View File

@@ -46,6 +46,7 @@ import {
type BudgetColumnProps = {
group: DashboardWorkingGroup;
workingGroups: DashboardWorkingGroup[];
viewer: DashboardViewer;
busy: boolean;
approvalThreshold: number;
@@ -60,6 +61,17 @@ type BudgetColumnProps = {
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
onDeleteBudget: (budgetId: string) => Promise<void>;
onDeleteExpense: (expenseId: string) => Promise<void>;
onUpdateExpense: (
expenseId: string,
draft: {
title: string;
description: string;
amount: string;
agId: string;
budgetId: string;
cutoffPhase: "PRE" | "POST";
}
) => Promise<void>;
};
type BudgetDraft = {
name: string;
@@ -113,6 +125,18 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
{expense.documentedAt ? (
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
) : null}
{expense.donationAmount > 0 ? (
<Chip
label={`Spende: ${formatCurrency(expense.donationAmount)}`}
size="small"
sx={{
...wrappingChipSx,
bgcolor: "#F6C343",
color: "#1F1600",
fontWeight: 700
}}
/>
) : null}
{expense.recurrence === "MONTHLY" ? (
<Chip
label={recurrenceLabel(expense.recurrence)}
@@ -140,6 +164,7 @@ function getPaidSpend(expenses: DashboardExpense[]) {
export function BudgetColumn({
group,
workingGroups,
viewer,
busy,
approvalThreshold,
@@ -153,7 +178,8 @@ export function BudgetColumn({
onDeleteWorkingGroup,
onSaveBudget,
onDeleteBudget,
onDeleteExpense
onDeleteExpense,
onUpdateExpense
}: BudgetColumnProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -165,13 +191,19 @@ export function BudgetColumn({
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const [expandedExpenseDetails, setExpandedExpenseDetails] = useState<Record<string, boolean>>({});
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
const [expenseDrafts, setExpenseDrafts] = useState<
Record<string, { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffPhase: "PRE" | "POST" }>
>({});
const budgetCardWidth = 318;
const desktopBudgetGap = 14;
const budgetCardWidth = 286;
const desktopBudgetGap = 12;
const desktopBudgetListWidth =
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
const groupCardWidth = Math.max(desktopBudgetListWidth + 58, 410);
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
const canEditBudgets = canManageBudgets(viewer.role);
const canEditExpenses = canManageBudgets(viewer.role);
useEffect(() => {
setBudgetDrafts(
@@ -234,6 +266,27 @@ export function BudgetColumn({
}));
}
function getExpenseDraft(expense: DashboardExpense) {
return expenseDrafts[expense.id] ?? {
title: expense.title,
description: expense.description ?? "",
amount: expense.amount.toFixed(2),
agId: group.id,
budgetId: expense.budgetId,
cutoffPhase: expense.cutoffPhase
};
}
function updateExpenseDraft(expense: DashboardExpense, patch: Partial<ReturnType<typeof getExpenseDraft>>) {
setExpenseDrafts((current) => ({
...current,
[expense.id]: {
...getExpenseDraft(expense),
...patch
}
}));
}
function resetDraft(budget: DashboardBudget) {
setBudgetDrafts((current) => ({
...current,
@@ -738,14 +791,15 @@ export function BudgetColumn({
: [];
const isRecurringSeries = expense.recurrence === "MONTHLY";
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
const isDetailsExpanded = expandedExpenseDetails[expense.id] ?? false;
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
return (
<Box
key={expense.id}
sx={{
p: 2.25,
borderRadius: "18px",
p: 1.55,
borderRadius: "14px",
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
backgroundColor:
expense.approvalStatus === "APPROVED"
@@ -754,8 +808,8 @@ export function BudgetColumn({
touchAction: "pan-x pan-y"
}}
>
<Stack spacing={1.4}>
<Stack spacing={1}>
<Stack spacing={1}>
<Stack spacing={0.75}>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
@@ -766,19 +820,171 @@ export function BudgetColumn({
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
{isRecurringSeries
? expense.occurrenceCount > 0
? `${formatCurrency(expense.netPeriodAmount)} netto im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
? `${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.netPeriodAmount)} netto von ${expense.creator.name}`}
: `${formatCurrency(expense.amount)} von ${expense.creator.name}`}
</Typography>
{expense.donationAmount > 0 ? (
<Typography variant="body2" color="text.secondary">
{`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`}
{`Rest: ${formatCurrency(expense.netPeriodAmount)}`}
</Typography>
) : null}
</Box>
<StatusChips expense={expense} />
</Stack>
<Button
type="button"
size="small"
variant="text"
onClick={() =>
setExpandedExpenseDetails((current) => ({
...current,
[expense.id]: !isDetailsExpanded
}))
}
endIcon={isDetailsExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
sx={{ alignSelf: "flex-start", px: 0 }}
>
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
</Button>
{canEditExpenses ? (
<Button
type="button"
size="small"
variant="outlined"
startIcon={<EditRoundedIcon />}
disabled={busy}
onClick={() => {
setEditingExpenseId(expense.id);
setExpenseDrafts((current) => ({
...current,
[expense.id]: getExpenseDraft(expense)
}));
}}
sx={{ alignSelf: "flex-start" }}
>
Bearbeiten
</Button>
) : null}
{editingExpenseId === expense.id ? (
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
{(() => {
const draft = getExpenseDraft(expense);
const editGroup =
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
const editBudgets = editGroup.budgets;
return (
<Stack spacing={1}>
<TextField
label="Titel"
size="small"
value={draft.title}
onChange={(event) => updateExpenseDraft(expense, { title: event.target.value })}
fullWidth
/>
<TextField
label="Beschreibung"
size="small"
value={draft.description}
onChange={(event) => updateExpenseDraft(expense, { description: event.target.value })}
fullWidth
multiline
minRows={2}
/>
<TextField
label="Betrag in EUR"
type="number"
size="small"
inputProps={{ min: 0.01, step: 0.01 }}
value={draft.amount}
onChange={(event) => updateExpenseDraft(expense, { amount: event.target.value })}
fullWidth
/>
<TextField
select
label="AG"
size="small"
value={draft.agId}
onChange={(event) => {
const nextGroup = workingGroups.find((entry) => entry.id === event.target.value);
updateExpenseDraft(expense, {
agId: event.target.value,
budgetId: nextGroup?.budgets[0]?.id ?? ""
});
}}
fullWidth
>
{workingGroups.map((entry) => (
<MenuItem key={entry.id} value={entry.id}>
{entry.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Budget"
size="small"
value={draft.budgetId}
onChange={(event) => updateExpenseDraft(expense, { budgetId: event.target.value })}
fullWidth
disabled={editBudgets.length === 0}
>
{editBudgets.map((entry) => (
<MenuItem key={entry.id} value={entry.id}>
{entry.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Stichtag"
size="small"
value={draft.cutoffPhase}
onChange={(event) =>
updateExpenseDraft(expense, {
cutoffPhase: event.target.value as "PRE" | "POST"
})
}
fullWidth
>
<MenuItem value="PRE">Pre Open Air</MenuItem>
<MenuItem value="POST">Post Open Air</MenuItem>
</TextField>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
size="small"
variant="contained"
disabled={busy || !draft.budgetId}
onClick={async () => {
await onUpdateExpense(expense.id, draft);
setEditingExpenseId(null);
}}
>
Speichern
</Button>
<Button
type="button"
size="small"
variant="text"
disabled={busy}
onClick={() => setEditingExpenseId(null)}
>
Abbrechen
</Button>
</Stack>
</Stack>
);
})()}
</Box>
) : null}
<Collapse in={isDetailsExpanded} unmountOnExit>
<Stack spacing={1}>
{expense.description ? (
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
{expense.description}
@@ -1075,6 +1281,8 @@ export function BudgetColumn({
new Date(expense.createdAt)
)}
</Typography>
</Stack>
</Collapse>
</Stack>
</Box>
);