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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user