Monatsauswahl und Spendenbearbeitung ergaenzen
All checks were successful
CI / Build and Deploy (push) Successful in 3m26s

This commit is contained in:
jan
2026-05-12 00:58:34 +02:00
parent a527a840ee
commit ae3a00a298
4 changed files with 254 additions and 9 deletions

View File

@@ -178,12 +178,17 @@ export default async function DashboardPage() {
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
const donationsByExpenseId = new Map<string, number>();
const donationRowsByExpenseId = new Map<string, typeof donationRows>();
for (const donation of donationRows) {
if (donation.expense_id) {
donationsByExpenseId.set(
donation.expense_id,
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
);
donationRowsByExpenseId.set(donation.expense_id, [
...(donationRowsByExpenseId.get(donation.expense_id) ?? []),
donation
]);
}
}
@@ -245,6 +250,13 @@ export default async function DashboardPage() {
recurrenceStartAt,
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
donations: (donationRowsByExpenseId.get(expense.id) ?? []).map((donation) => ({
id: donation.id,
title: donation.title,
description: donation.description,
amount: Number(donation.amount),
donatedAt: donation.donated_at.toISOString()
})),
netPeriodAmount: Math.max(
0,
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -

View File

@@ -32,7 +32,13 @@ import { alpha, useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
import type {
DashboardBudget,
DashboardExpense,
DashboardExpenseDonation,
DashboardViewer,
DashboardWorkingGroup
} from "@/lib/dashboard-types";
import {
approvalLabel,
canDeleteExpense,
@@ -72,6 +78,19 @@ type BudgetColumnProps = {
cutoffPhase: "PRE" | "POST";
}
) => Promise<void>;
onUpdateDonation: (
donationId: string,
draft: {
title: string;
description: string;
amount: string;
donatedAt: string;
target: "GENERAL" | "EXPENSE";
workingGroupId: string;
expenseId: string;
}
) => Promise<void>;
onDeleteDonation: (donationId: string, title: string) => Promise<void>;
};
type BudgetDraft = {
name: string;
@@ -79,6 +98,16 @@ type BudgetDraft = {
colorCode: string;
};
type AssignedDonationDraft = {
title: string;
description: string;
amount: string;
donatedAt: string;
target: "GENERAL" | "EXPENSE";
workingGroupId: string;
expenseId: string;
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR"
@@ -109,7 +138,15 @@ function createDraft(budget: DashboardBudget): BudgetDraft {
};
}
function StatusChips({ expense }: { expense: DashboardExpense }) {
function StatusChips({
expense,
canEditDonations,
onEditDonation
}: {
expense: DashboardExpense;
canEditDonations: boolean;
onEditDonation: (donation: DashboardExpenseDonation) => void;
}) {
return (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip
@@ -125,18 +162,24 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
{expense.documentedAt ? (
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
) : null}
{expense.donationAmount > 0 ? (
{expense.donations.map((donation) => (
<Chip
label={`Spende: ${formatCurrency(expense.donationAmount)}`}
key={donation.id}
label={`Spende: ${formatCurrency(donation.amount)}`}
size="small"
onDelete={canEditDonations ? () => onEditDonation(donation) : undefined}
deleteIcon={canEditDonations ? <EditRoundedIcon /> : undefined}
sx={{
...wrappingChipSx,
bgcolor: "#F6C343",
color: "#1F1600",
fontWeight: 700
fontWeight: 700,
"& .MuiChip-deleteIcon": {
color: "#1F1600"
}
}}
/>
) : null}
))}
{expense.recurrence === "MONTHLY" ? (
<Chip
label={recurrenceLabel(expense.recurrence)}
@@ -179,7 +222,9 @@ export function BudgetColumn({
onSaveBudget,
onDeleteBudget,
onDeleteExpense,
onUpdateExpense
onUpdateExpense,
onUpdateDonation,
onDeleteDonation
}: BudgetColumnProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -193,9 +238,11 @@ export function BudgetColumn({
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const [expandedExpenseDetails, setExpandedExpenseDetails] = useState<Record<string, boolean>>({});
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
const [editingAssignedDonationId, setEditingAssignedDonationId] = useState<string | null>(null);
const [expenseDrafts, setExpenseDrafts] = useState<
Record<string, { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffPhase: "PRE" | "POST" }>
>({});
const [assignedDonationDrafts, setAssignedDonationDrafts] = useState<Record<string, AssignedDonationDraft>>({});
const budgetCardWidth = 286;
const desktopBudgetGap = 12;
@@ -204,6 +251,7 @@ export function BudgetColumn({
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
const canEditBudgets = canManageBudgets(viewer.role);
const canEditExpenses = canManageBudgets(viewer.role);
const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE";
useEffect(() => {
setBudgetDrafts(
@@ -287,6 +335,32 @@ export function BudgetColumn({
}));
}
function getAssignedDonationDraft(expense: DashboardExpense, donation: DashboardExpenseDonation): AssignedDonationDraft {
return assignedDonationDrafts[donation.id] ?? {
title: donation.title,
description: donation.description ?? "",
amount: donation.amount.toFixed(2),
donatedAt: donation.donatedAt.slice(0, 10),
target: "EXPENSE",
workingGroupId: group.id,
expenseId: expense.id
};
}
function updateAssignedDonationDraft(
expense: DashboardExpense,
donation: DashboardExpenseDonation,
patch: Partial<AssignedDonationDraft>
) {
setAssignedDonationDrafts((current) => ({
...current,
[donation.id]: {
...getAssignedDonationDraft(expense, donation),
...patch
}
}));
}
function resetDraft(budget: DashboardBudget) {
setBudgetDrafts((current) => ({
...current,
@@ -830,9 +904,121 @@ export function BudgetColumn({
</Typography>
) : null}
</Box>
<StatusChips expense={expense} />
<StatusChips
expense={expense}
canEditDonations={canEditDonations}
onEditDonation={(donation) => {
setEditingAssignedDonationId(donation.id);
setAssignedDonationDrafts((current) => ({
...current,
[donation.id]: getAssignedDonationDraft(expense, donation)
}));
}}
/>
</Stack>
{expense.donations.map((donation) => {
if (editingAssignedDonationId !== donation.id) {
return null;
}
const donationDraft = getAssignedDonationDraft(expense, donation);
return (
<Box
key={donation.id}
sx={{
p: 1.2,
borderRadius: "14px",
bgcolor: alpha("#F6C343", 0.16),
border: `1px solid ${alpha("#F6C343", 0.36)}`
}}
>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
Spende bearbeiten
</Typography>
<TextField
label="Titel"
size="small"
value={donationDraft.title}
onChange={(event) => updateAssignedDonationDraft(expense, donation, { title: event.target.value })}
fullWidth
/>
<TextField
label="Betrag in EUR"
type="number"
size="small"
inputProps={{ min: 0.01, step: 0.01 }}
value={donationDraft.amount}
onChange={(event) => updateAssignedDonationDraft(expense, donation, { amount: event.target.value })}
fullWidth
/>
<TextField
label="Spendendatum"
type="date"
size="small"
value={donationDraft.donatedAt}
onChange={(event) => updateAssignedDonationDraft(expense, donation, { donatedAt: event.target.value })}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="Beschreibung"
size="small"
value={donationDraft.description}
onChange={(event) =>
updateAssignedDonationDraft(expense, donation, { description: event.target.value })
}
fullWidth
multiline
minRows={2}
/>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
size="small"
variant="contained"
disabled={busy}
onClick={async () => {
await onUpdateDonation(donation.id, donationDraft);
setEditingAssignedDonationId(null);
}}
>
Speichern
</Button>
<Button
type="button"
size="small"
color="error"
variant="outlined"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
return;
}
await onDeleteDonation(donation.id, donation.title);
setEditingAssignedDonationId(null);
}}
>
Löschen
</Button>
<Button
type="button"
size="small"
variant="text"
disabled={busy}
onClick={() => setEditingAssignedDonationId(null)}
>
Abbrechen
</Button>
</Stack>
</Stack>
</Box>
);
})}
<Button
type="button"
size="small"

View File

@@ -429,6 +429,7 @@ export function DashboardShell({
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [financeViewMode, setFinanceViewMode] = useState<FinanceViewMode>("monthly");
const [financePresentation, setFinancePresentation] = useState<FinancePresentation>("charts");
const [selectedFinanceMonth, setSelectedFinanceMonth] = useState("ALL");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
@@ -3259,6 +3260,8 @@ export function DashboardShell({
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
onUpdateDonation={handleUpdateDonation}
onDeleteDonation={handleDeleteDonation}
/>
</Box>
))}
@@ -3309,6 +3312,8 @@ export function DashboardShell({
onDeleteBudget={handleDeleteBudget}
onDeleteExpense={handleDeleteExpense}
onUpdateExpense={handleUpdateExpense}
onUpdateDonation={handleUpdateDonation}
onDeleteDonation={handleDeleteDonation}
/>
</Box>
))}
@@ -3350,7 +3355,10 @@ export function DashboardShell({
row.donations += donation.amount;
rows.set(key, row);
}
return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, row]) => row);
return [...rows.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.filter(([key]) => selectedFinanceMonth === "ALL" || key === selectedFinanceMonth)
.map(([, row]) => row);
}
if (financeViewMode === "cutoff") {
@@ -3397,6 +3405,20 @@ export function DashboardShell({
}
];
})();
const financeMonthOptions = (() => {
const rows = new Map<string, string>();
for (const expense of allExpenses) {
const date = new Date(expense.createdAt);
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
rows.set(key, new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date));
}
for (const donation of donations) {
const date = new Date(donation.donatedAt);
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
rows.set(key, new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date));
}
return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right));
})();
const financeOverviewContent = (
<Stack spacing={2.5}>
@@ -3425,6 +3447,22 @@ export function DashboardShell({
<MenuItem value="charts">Grafisch</MenuItem>
<MenuItem value="table">Tabellarisch</MenuItem>
</TextField>
{financeViewMode === "monthly" ? (
<TextField
select
label="Monat"
value={selectedFinanceMonth}
onChange={(event) => setSelectedFinanceMonth(event.target.value)}
fullWidth
>
<MenuItem value="ALL">Alle Monate</MenuItem>
{financeMonthOptions.map(([monthKey, monthLabel]) => (
<MenuItem key={monthKey} value={monthKey}>
{monthLabel}
</MenuItem>
))}
</TextField>
) : null}
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />

View File

@@ -58,6 +58,14 @@ export type DashboardExpenseDocument = {
};
};
export type DashboardExpenseDonation = {
id: string;
title: string;
description: string | null;
amount: number;
donatedAt: string;
};
export type DashboardExpense = {
id: string;
title: string;
@@ -73,6 +81,7 @@ export type DashboardExpense = {
recurrenceStartAt: string | null;
cutoffPhase: CutoffPhaseValue;
donationAmount: number;
donations: DashboardExpenseDonation[];
netPeriodAmount: number;
paidAt: string | null;
documentedAt: string | null;