Ausgaben und Spenden bearbeitbar machen
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user