Monatsauswahl und Spendenbearbeitung ergaenzen
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
+194 -8
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"