Ausgaben und Spenden bearbeitbar machen
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
+219 -11
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>
);
+366 -68
View File
@@ -100,9 +100,21 @@ type DonationFormState = {
amount: string;
donatedAt: string;
target: "GENERAL" | "EXPENSE";
workingGroupId: string;
expenseId: string;
};
type DonationDraft = DonationFormState;
type ExpenseEditDraft = {
title: string;
description: string;
amount: string;
agId: string;
budgetId: string;
cutoffPhase: "PRE" | "POST";
};
type WorkingGroupFormState = {
name: string;
};
@@ -337,7 +349,7 @@ export function DashboardShell({
const desktopSections = [
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "AGs & Budgets" }] : []),
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
@@ -387,6 +399,7 @@ export function DashboardShell({
amount: "",
donatedAt: toDateInputValue(new Date().toISOString()),
target: "GENERAL",
workingGroupId: visibleGroups[0]?.id ?? "",
expenseId: ""
});
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
@@ -428,6 +441,8 @@ export function DashboardShell({
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
const [donationDrafts, setDonationDrafts] = useState<Record<string, DonationDraft>>({});
const [editingDonationId, setEditingDonationId] = useState<string | null>(null);
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
requiredApprovalTypes: settings.requiredApprovalTypes,
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
@@ -745,6 +760,10 @@ export function DashboardShell({
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
selectedBudgetReleaseOptions[0] ??
null;
const selectedDonationGroup =
visibleGroups.find((group) => group.id === donationForm.workingGroupId) ?? visibleGroups[0] ?? null;
const selectedDonationGroupExpenses =
selectedDonationGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
const selectedBudgetReleasePaidAmount =
selectedBudgetReleaseBudget?.expenses.reduce(
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
@@ -791,6 +810,28 @@ export function DashboardShell({
}
}));
}
function getDonationDraft(donation: DashboardDonation): DonationDraft {
return donationDrafts[donation.id] ?? {
title: donation.title,
description: donation.description ?? "",
amount: donation.amount.toFixed(2),
donatedAt: toDateInputValue(donation.donatedAt),
target: donation.expenseId ? "EXPENSE" : "GENERAL",
workingGroupId: donation.workingGroupId ?? visibleGroups[0]?.id ?? "",
expenseId: donation.expenseId ?? ""
};
}
function updateDonationDraft(donation: DashboardDonation, patch: Partial<DonationDraft>) {
setDonationDrafts((current) => ({
...current,
[donation.id]: {
...getDonationDraft(donation),
...patch
}
}));
}
const totals = useMemo(() => {
return visibleGroups.reduce(
(summary, group) => {
@@ -1153,6 +1194,7 @@ export function DashboardShell({
amount: "",
donatedAt: toDateInputValue(new Date().toISOString()),
target: "GENERAL",
workingGroupId: visibleGroups[0]?.id ?? "",
expenseId: ""
});
}, "Spende wurde erfasst.");
@@ -1178,6 +1220,51 @@ export function DashboardShell({
}, "Ausgabe wurde gel\u00f6scht.");
}
async function handleUpdateExpense(expenseId: string, draft: ExpenseEditDraft) {
await runAction(async () => {
await parseResponse(
await fetch(`/api/expenses/${expenseId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(draft)
})
);
}, "Ausgabe wurde bearbeitet.");
}
async function handleUpdateDonation(donationId: string, draft: DonationDraft) {
await runAction(async () => {
await parseResponse(
await fetch(`/api/donations/${donationId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
title: draft.title,
description: draft.description,
amount: draft.amount,
donatedAt: draft.donatedAt,
expenseId: draft.target === "EXPENSE" ? draft.expenseId : ""
})
})
);
setEditingDonationId(null);
}, "Spende wurde bearbeitet.");
}
async function handleDeleteDonation(donationId: string, title: string) {
await runAction(async () => {
await parseResponse(
await fetch(`/api/donations/${donationId}`, {
method: "DELETE"
})
);
}, `Spende ${title} wurde gelöscht.`);
}
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -1796,44 +1883,6 @@ export function DashboardShell({
</Stack>
</Box>
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Stichtage
</Typography>
<Typography variant="body2" color="text.secondary">
Dieser Stichtag trennt Ausgaben in Pre/Post und wird in der Finanzübersicht ausgewertet.
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
<TextField
label="Stichtag-Name"
value={periodEditForm.cutoffName}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Datum"
type="date"
value={periodEditForm.cutoffDate}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!selectedPeriodForManagement}
/>
</Stack>
<Button
type="submit"
variant="outlined"
startIcon={<EditRoundedIcon />}
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
>
Stichtag speichern
</Button>
</Stack>
</Box>
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -1893,6 +1942,44 @@ export function DashboardShell({
</Box>
</Stack>
) : null;
const cutoffManagementPanel = canManagePeriods ? (
<Box component="form" onSubmit={handleSavePeriod}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Stichtage
</Typography>
<Typography variant="body2" color="text.secondary">
Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten.
</Typography>
<TextField
label="Stichtag-Name"
value={periodEditForm.cutoffName}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Datum"
type="date"
value={periodEditForm.cutoffDate}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!selectedPeriodForManagement}
/>
<Button
type="submit"
variant="outlined"
startIcon={<EditRoundedIcon />}
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
>
Stichtag speichern
</Button>
</Stack>
</Box>
) : null;
const actionCards = (
<Stack
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
@@ -2230,21 +2317,44 @@ export function DashboardShell({
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
</TextField>
{donationForm.target === "EXPENSE" ? (
<TextField
select
label="Ausgabe"
value={donationForm.expenseId}
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
required
fullWidth
disabled={allExpenses.length === 0}
>
{allExpenses.map((expense) => (
<MenuItem key={expense.id} value={expense.id}>
{expense.title} · {currencyFormatter.format(expense.netPeriodAmount)}
</MenuItem>
))}
</TextField>
<>
<TextField
select
label="AG"
value={donationForm.workingGroupId}
onChange={(event) =>
setDonationForm((current) => ({
...current,
workingGroupId: event.target.value,
expenseId: ""
}))
}
required
fullWidth
disabled={visibleGroups.length === 0}
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Ausgabe"
value={donationForm.expenseId}
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
required
fullWidth
disabled={selectedDonationGroupExpenses.length === 0}
>
{selectedDonationGroupExpenses.map((expense) => (
<MenuItem key={expense.id} value={expense.id}>
{expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)}
</MenuItem>
))}
</TextField>
</>
) : null}
<Button type="submit" variant="outlined" disabled={busy}>
Spende speichern
@@ -2371,9 +2481,14 @@ export function DashboardShell({
) : null}
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
<Stack spacing={3}>
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
</Card>
</Stack>
) : null}
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
@@ -2921,6 +3036,172 @@ export function DashboardShell({
const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null;
const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups;
const generalDonations = donations.filter((donation) => !donation.expenseId);
function renderDonationEditor(donation: DashboardDonation) {
const draft = getDonationDraft(donation);
const draftGroup = visibleGroups.find((group) => group.id === draft.workingGroupId) ?? visibleGroups[0] ?? null;
const draftExpenses = draftGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
if (editingDonationId !== donation.id) {
return (
<Stack spacing={0.8}>
<Stack direction="row" justifyContent="space-between" gap={1}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, overflowWrap: "break-word" }}>
{donation.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{currencyFormatter.format(donation.amount)}
{donation.expenseTitle ? ` · ${donation.expenseTitle}` : ""}
</Typography>
</Box>
<Stack direction="row" gap={0.5}>
<IconButton size="small" disabled={busy} onClick={() => setEditingDonationId(donation.id)}>
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
return;
}
await handleDeleteDonation(donation.id, donation.title);
}}
>
<DeleteOutlineRoundedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
</Stack>
);
}
return (
<Stack spacing={1}>
<TextField
label="Titel"
size="small"
value={draft.title}
onChange={(event) => updateDonationDraft(donation, { title: event.target.value })}
fullWidth
/>
<TextField
label="Betrag in EUR"
type="number"
size="small"
inputProps={{ min: 0.01, step: 0.01 }}
value={draft.amount}
onChange={(event) => updateDonationDraft(donation, { amount: event.target.value })}
fullWidth
/>
<TextField
label="Spendendatum"
type="date"
size="small"
value={draft.donatedAt}
onChange={(event) => updateDonationDraft(donation, { donatedAt: event.target.value })}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
select
label="Zuordnung"
size="small"
value={draft.target}
onChange={(event) =>
updateDonationDraft(donation, {
target: event.target.value as DonationDraft["target"],
expenseId: ""
})
}
fullWidth
>
<MenuItem value="GENERAL">Allgemein</MenuItem>
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
</TextField>
{draft.target === "EXPENSE" ? (
<>
<TextField
select
label="AG"
size="small"
value={draft.workingGroupId}
onChange={(event) =>
updateDonationDraft(donation, {
workingGroupId: event.target.value,
expenseId: ""
})
}
fullWidth
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Ausgabe"
size="small"
value={draft.expenseId}
onChange={(event) => updateDonationDraft(donation, { expenseId: event.target.value })}
fullWidth
disabled={draftExpenses.length === 0}
>
{draftExpenses.map((expense) => (
<MenuItem key={expense.id} value={expense.id}>
{expense.title}
</MenuItem>
))}
</TextField>
</>
) : null}
<Stack direction="row" gap={1}>
<Button size="small" variant="contained" disabled={busy} onClick={() => handleUpdateDonation(donation.id, draft)}>
Speichern
</Button>
<Button size="small" variant="text" disabled={busy} onClick={() => setEditingDonationId(null)}>
Abbrechen
</Button>
</Stack>
</Stack>
);
}
const generalDonationsColumn = (
<Card sx={{ ...islandCardSx, width: { xs: "100%", lg: 286 }, flex: "0 0 auto" }}>
<CardContent sx={{ p: 2.2 }}>
<Stack spacing={1.4}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
Spenden
</Typography>
<Typography color="text.secondary">
Allgemein: {currencyFormatter.format(generalDonationTotal)}
</Typography>
</Box>
{generalDonations.length === 0 ? (
<Box sx={{ p: 1.5, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.12) }}>
<Typography variant="body2" color="text.secondary">
Noch keine allgemeinen Spenden.
</Typography>
</Box>
) : (
generalDonations.map((donation) => (
<Box key={donation.id} sx={{ p: 1.4, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.16) }}>
{renderDonationEditor(donation)}
</Box>
))
)}
</Stack>
</CardContent>
</Card>
);
const overviewContent = (
<Stack spacing={2.5}>
@@ -2962,6 +3243,7 @@ export function DashboardShell({
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
group={group}
workingGroups={visibleGroups}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
@@ -2976,9 +3258,11 @@ export function DashboardShell({
onSaveBudget={handleSaveBudget}
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
/>
</Box>
))}
{generalDonationsColumn}
</Stack>
</Box>
) : (
@@ -3009,6 +3293,7 @@ export function DashboardShell({
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
group={group}
workingGroups={visibleGroups}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
@@ -3023,9 +3308,11 @@ export function DashboardShell({
onSaveBudget={handleSaveBudget}
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
/>
</Box>
))}
{generalDonationsColumn}
</Stack>
</Box>
)}
@@ -3069,27 +3356,33 @@ export function DashboardShell({
if (financeViewMode === "cutoff") {
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
const cutoffDate = currentPeriod?.cutoffDate ? new Date(currentPeriod.cutoffDate) : null;
const preGeneralDonations = donations
.filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate))
.reduce((sum, donation) => sum + donation.amount, 0);
const postGeneralDonations = donations
.filter((donation) => !donation.expenseId && cutoffDate && new Date(donation.donatedAt) > cutoffDate)
.reduce((sum, donation) => sum + donation.amount, 0);
const preAssignedDonations = donations
.filter((donation) => donation.expenseId && pre.some((expense) => expense.id === donation.expenseId))
.reduce((sum, donation) => sum + donation.amount, 0);
const postAssignedDonations = donations
.filter((donation) => donation.expenseId && post.some((expense) => expense.id === donation.expenseId))
.reduce((sum, donation) => sum + donation.amount, 0);
return [
{
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
donations: assignedDonationTotal
donations: preAssignedDonations + preGeneralDonations
},
{
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
donations: 0
},
{
label: "Allgemeine Spenden",
planned: 0,
approved: 0,
paid: 0,
donations: generalDonationTotal
donations: postAssignedDonations + postGeneralDonations
}
];
}
@@ -3135,9 +3428,9 @@ export function DashboardShell({
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
<Chip label={`Bezahlt netto: ${currencyFormatter.format(paidTotal)}`} color="info" />
<Chip label={`Bezahlt: ${currencyFormatter.format(paidTotal)}`} color="info" />
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
<Chip label={`Netto-Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
<Chip label={`Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
</Stack>
</Stack>
</CardContent>
@@ -3213,6 +3506,11 @@ export function DashboardShell({
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
) : null}
{canManagePeriods ? (
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 420 }, flexShrink: 0 }}>
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
</Card>
) : null}
</Stack>
) : (
<Box sx={{ width: "100%" }}>{actionCards}</Box>