Monatsauswahl und Spendenbearbeitung ergaenzen
All checks were successful
CI / Build and Deploy (push) Successful in 3m26s
All checks were successful
CI / Build and Deploy (push) Successful in 3m26s
This commit is contained in:
@@ -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) -
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}`} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user