This commit is contained in:
@@ -127,15 +127,15 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
||||
}
|
||||
|
||||
function getApprovedSpend(expenses: DashboardExpense[]) {
|
||||
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), 0);
|
||||
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0);
|
||||
}
|
||||
|
||||
function getPendingSpend(expenses: DashboardExpense[]) {
|
||||
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0);
|
||||
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0);
|
||||
}
|
||||
|
||||
function getPaidSpend(expenses: DashboardExpense[]) {
|
||||
return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0);
|
||||
return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0);
|
||||
}
|
||||
|
||||
export function BudgetColumn({
|
||||
@@ -166,11 +166,11 @@ export function BudgetColumn({
|
||||
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||
|
||||
const budgetCardWidth = 352;
|
||||
const desktopBudgetGap = 16;
|
||||
const budgetCardWidth = 318;
|
||||
const desktopBudgetGap = 14;
|
||||
const desktopBudgetListWidth =
|
||||
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 64, 456);
|
||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 58, 410);
|
||||
const canEditBudgets = canManageBudgets(viewer.role);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -766,10 +766,15 @@ export function BudgetColumn({
|
||||
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||
{isRecurringSeries
|
||||
? expense.occurrenceCount > 0
|
||||
? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
||||
? `${formatCurrency(expense.netPeriodAmount)} netto 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.amount)} von ${expense.creator.name}`}
|
||||
: `${formatCurrency(expense.netPeriodAmount)} netto von ${expense.creator.name}`}
|
||||
</Typography>
|
||||
{expense.donationAmount > 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<StatusChips expense={expense} />
|
||||
</Stack>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
||||
import type {
|
||||
DashboardAccountingPeriod,
|
||||
DashboardAuditLog,
|
||||
DashboardDonation,
|
||||
DashboardManagedUser,
|
||||
DashboardSettings,
|
||||
DashboardViewer,
|
||||
@@ -66,6 +67,7 @@ type DashboardShellProps = {
|
||||
accountingPeriods: DashboardAccountingPeriod[];
|
||||
currentPeriodId: string;
|
||||
settings: DashboardSettings;
|
||||
donations: DashboardDonation[];
|
||||
};
|
||||
|
||||
type ExpenseFormState = {
|
||||
@@ -76,6 +78,7 @@ type ExpenseFormState = {
|
||||
budgetId: string;
|
||||
recurrence: "NONE" | "MONTHLY";
|
||||
recurrenceStartAt: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
};
|
||||
|
||||
type BudgetFormState = {
|
||||
@@ -91,6 +94,15 @@ type BudgetReleaseFormState = {
|
||||
releasedAmount: string;
|
||||
};
|
||||
|
||||
type DonationFormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
donatedAt: string;
|
||||
target: "GENERAL" | "EXPENSE";
|
||||
expenseId: string;
|
||||
};
|
||||
|
||||
type WorkingGroupFormState = {
|
||||
name: string;
|
||||
};
|
||||
@@ -120,6 +132,8 @@ type PeriodEditFormState = {
|
||||
name: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
cutoffName: string;
|
||||
cutoffDate: string;
|
||||
};
|
||||
|
||||
type OrgaSettingsDraft = {
|
||||
@@ -172,9 +186,10 @@ function sortManagedUsersList(users: DashboardManagedUser[]) {
|
||||
}
|
||||
|
||||
|
||||
type MobileSection = "overview" | "actions";
|
||||
type MobileSection = "overview" | "finance" | "actions";
|
||||
type MobileAction =
|
||||
| "expense"
|
||||
| "donation"
|
||||
| "budgetRelease"
|
||||
| "workingGroup"
|
||||
| "budget"
|
||||
@@ -184,7 +199,9 @@ type MobileAction =
|
||||
| "approvalThreshold"
|
||||
| "users"
|
||||
| "logs";
|
||||
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
|
||||
type FinanceViewMode = "monthly" | "yearly" | "cutoff";
|
||||
type FinancePresentation = "charts" | "table";
|
||||
type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs";
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
@@ -234,14 +251,18 @@ function getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined
|
||||
return {
|
||||
name: "",
|
||||
startsAt: "",
|
||||
endsAt: ""
|
||||
endsAt: "",
|
||||
cutoffName: "Open Air",
|
||||
cutoffDate: ""
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: period.name,
|
||||
startsAt: toDateInputValue(period.startsAt),
|
||||
endsAt: toDateInputValue(period.endsAt)
|
||||
endsAt: toDateInputValue(period.endsAt),
|
||||
cutoffName: period.cutoffName || "Open Air",
|
||||
cutoffDate: period.cutoffDate ? toDateInputValue(period.cutoffDate) : ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,7 +316,8 @@ export function DashboardShell({
|
||||
auditLogs,
|
||||
accountingPeriods,
|
||||
currentPeriodId,
|
||||
settings
|
||||
settings,
|
||||
donations
|
||||
}: DashboardShellProps) {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
@@ -313,7 +335,8 @@ export function DashboardShell({
|
||||
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
||||
const approvalThreshold = settings.approvalThreshold;
|
||||
const desktopSections = [
|
||||
{ value: "overview" as const, label: "\u00dcbersicht" },
|
||||
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
|
||||
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
|
||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
||||
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
||||
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
||||
@@ -321,6 +344,7 @@ export function DashboardShell({
|
||||
];
|
||||
const mobileActions = [
|
||||
{ value: "expense" as const, label: "Neue Ausgabe" },
|
||||
...(canManagePeriods ? [{ value: "donation" as const, label: "Spende erfassen" }] : []),
|
||||
...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []),
|
||||
...(canManageBudgets(viewer.role)
|
||||
? [
|
||||
@@ -354,7 +378,16 @@ export function DashboardShell({
|
||||
agId: defaultEditableGroup?.id ?? "",
|
||||
budgetId: defaultBudget?.id ?? "",
|
||||
recurrence: "NONE",
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||
cutoffPhase: "PRE"
|
||||
});
|
||||
const [donationForm, setDonationForm] = useState<DonationFormState>({
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||
target: "GENERAL",
|
||||
expenseId: ""
|
||||
});
|
||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||
@@ -374,13 +407,15 @@ export function DashboardShell({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "MEMBER",
|
||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||
workingGroupId: ""
|
||||
});
|
||||
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
||||
const [selectedMobileAction, setSelectedMobileAction] = useState<MobileAction>("expense");
|
||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||
const [financeViewMode, setFinanceViewMode] = useState<FinanceViewMode>("monthly");
|
||||
const [financePresentation, setFinancePresentation] = useState<FinancePresentation>("charts");
|
||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
||||
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
|
||||
@@ -576,7 +611,7 @@ export function DashboardShell({
|
||||
if (!groupStillExists) {
|
||||
setBudgetForm((current) => ({
|
||||
...current,
|
||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||
workingGroupId: ""
|
||||
}));
|
||||
}
|
||||
}, [budgetForm.workingGroupId, visibleGroups]);
|
||||
@@ -667,12 +702,13 @@ export function DashboardShell({
|
||||
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
||||
const groupStillExists =
|
||||
userForm.workingGroupId === "" || visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
||||
|
||||
if (!groupStillExists) {
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||
workingGroupId: ""
|
||||
}));
|
||||
}
|
||||
}, [userForm.workingGroupId, visibleGroups]);
|
||||
@@ -711,7 +747,7 @@ export function DashboardShell({
|
||||
null;
|
||||
const selectedBudgetReleasePaidAmount =
|
||||
selectedBudgetReleaseBudget?.expenses.reduce(
|
||||
(sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0),
|
||||
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
|
||||
0
|
||||
) ?? 0;
|
||||
const selectedPeriodForManagement =
|
||||
@@ -720,7 +756,14 @@ export function DashboardShell({
|
||||
selectedPeriodForManagement !== null &&
|
||||
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
|
||||
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
|
||||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
|
||||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt) ||
|
||||
periodEditForm.cutoffName.trim() !== selectedPeriodForManagement.cutoffName ||
|
||||
periodEditForm.cutoffDate !== (selectedPeriodForManagement.cutoffDate ? toDateInputValue(selectedPeriodForManagement.cutoffDate) : ""));
|
||||
|
||||
const allExpenses = useMemo(
|
||||
() => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)),
|
||||
[visibleGroups]
|
||||
);
|
||||
|
||||
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
||||
return userDrafts[user.id] ?? {
|
||||
@@ -755,7 +798,7 @@ export function DashboardShell({
|
||||
(groupSum, budget) =>
|
||||
groupSum +
|
||||
budget.expenses.reduce(
|
||||
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0),
|
||||
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0),
|
||||
0
|
||||
),
|
||||
0
|
||||
@@ -764,7 +807,7 @@ export function DashboardShell({
|
||||
(groupSum, budget) =>
|
||||
groupSum +
|
||||
budget.expenses.reduce(
|
||||
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0),
|
||||
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0),
|
||||
0
|
||||
),
|
||||
0
|
||||
@@ -796,6 +839,23 @@ export function DashboardShell({
|
||||
);
|
||||
}, [visibleGroups]);
|
||||
|
||||
const generalDonationTotal = useMemo(
|
||||
() => donations.reduce((sum, donation) => sum + (donation.expenseId ? 0 : donation.amount), 0),
|
||||
[donations]
|
||||
);
|
||||
const assignedDonationTotal = useMemo(
|
||||
() => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0),
|
||||
[donations]
|
||||
);
|
||||
const preCutoffExpenses = useMemo(
|
||||
() => allExpenses.reduce((sum, expense) => sum + (expense.cutoffPhase === "PRE" ? expense.netPeriodAmount : 0), 0),
|
||||
[allExpenses]
|
||||
);
|
||||
const paidTotal = useMemo(
|
||||
() => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
[allExpenses]
|
||||
);
|
||||
|
||||
async function runAction<T>(
|
||||
task: () => Promise<T>,
|
||||
successMessage: string | ((result: T) => string)
|
||||
@@ -852,7 +912,8 @@ export function DashboardShell({
|
||||
agId: expenseForm.agId,
|
||||
budgetId: expenseForm.budgetId,
|
||||
recurrence: expenseForm.recurrence,
|
||||
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : ""
|
||||
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
|
||||
cutoffPhase: expenseForm.cutoffPhase
|
||||
})
|
||||
})
|
||||
);
|
||||
@@ -867,7 +928,8 @@ export function DashboardShell({
|
||||
agId: resetGroup,
|
||||
budgetId: resetBudget,
|
||||
recurrence: "NONE",
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
||||
cutoffPhase: "PRE"
|
||||
});
|
||||
}, "Ausgabe wurde gespeichert.");
|
||||
}
|
||||
@@ -1054,6 +1116,48 @@ export function DashboardShell({
|
||||
}, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`);
|
||||
}
|
||||
|
||||
async function handleCreateDonation(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentPeriod) {
|
||||
setMessage({ type: "error", text: "Bitte zuerst einen aktuellen Zeitraum auswählen." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (donationForm.target === "EXPENSE" && !donationForm.expenseId) {
|
||||
setMessage({ type: "error", text: "Bitte die Ausgabe auswählen, der die Spende zugeordnet werden soll." });
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch("/api/donations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: donationForm.title,
|
||||
description: donationForm.description,
|
||||
amount: donationForm.amount,
|
||||
donatedAt: donationForm.donatedAt,
|
||||
periodId: currentPeriod.id,
|
||||
expenseId: donationForm.target === "EXPENSE" ? donationForm.expenseId : ""
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
setDonationForm({
|
||||
title: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||
target: "GENERAL",
|
||||
expenseId: ""
|
||||
});
|
||||
}, "Spende wurde erfasst.");
|
||||
}
|
||||
|
||||
async function handleDeleteBudget(budgetId: string) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
@@ -1207,7 +1311,7 @@ export function DashboardShell({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "MEMBER",
|
||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||
workingGroupId: ""
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1692,6 +1796,44 @@ 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 }}>
|
||||
@@ -1798,6 +1940,12 @@ export function DashboardShell({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{viewer.role === "MEMBER" && !viewer.workingGroupId ? (
|
||||
<Alert severity="info">
|
||||
Du bist noch keiner AG zugeordnet. Du kannst dich anmelden, aber Ausgaben erst erfassen, wenn dir eine AG zugewiesen wurde.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Box component="form" onSubmit={handleCreateExpense}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
@@ -1855,6 +2003,22 @@ export function DashboardShell({
|
||||
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
|
||||
/>
|
||||
) : null}
|
||||
<TextField
|
||||
select
|
||||
label={`Stichtag-Zuordnung (${currentPeriod?.cutoffName ?? "Open Air"})`}
|
||||
value={expenseForm.cutoffPhase}
|
||||
onChange={(event) =>
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"]
|
||||
}))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="PRE">{`Pre ${currentPeriod?.cutoffName ?? "Open Air"}`}</MenuItem>
|
||||
<MenuItem value="POST">{`Post ${currentPeriod?.cutoffName ?? "Open Air"}`}</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Arbeitsgruppe"
|
||||
@@ -2002,6 +2166,96 @@ export function DashboardShell({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canManagePeriods && (isCompactLayout ? selectedMobileAction === "donation" : desktopSection === "overview") ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
|
||||
Spende erfassen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: "0.9rem" }}>
|
||||
Allgemeine Spenden zählen global, zugeordnete Spenden entlasten direkt eine Ausgabe.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="form" onSubmit={handleCreateDonation}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Titel"
|
||||
value={donationForm.title}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, title: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Beschreibung"
|
||||
value={donationForm.description}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, description: event.target.value }))}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
<TextField
|
||||
label="Betrag in EUR"
|
||||
type="number"
|
||||
inputProps={{ min: 0.01, step: 0.01 }}
|
||||
value={donationForm.amount}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, amount: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Spendendatum"
|
||||
type="date"
|
||||
value={donationForm.donatedAt}
|
||||
onChange={(event) => setDonationForm((current) => ({ ...current, donatedAt: event.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Zuordnung"
|
||||
value={donationForm.target}
|
||||
onChange={(event) =>
|
||||
setDonationForm((current) => ({
|
||||
...current,
|
||||
target: event.target.value as DonationFormState["target"],
|
||||
expenseId: ""
|
||||
}))
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="GENERAL">Allgemein</MenuItem>
|
||||
<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>
|
||||
) : null}
|
||||
<Button type="submit" variant="outlined" disabled={busy}>
|
||||
Spende speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
@@ -2249,16 +2503,15 @@ export function DashboardShell({
|
||||
}
|
||||
fullWidth
|
||||
disabled={visibleGroups.length === 0}
|
||||
required={userForm.role === "MEMBER"}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: userForm.role === "MEMBER"
|
||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: userForm.role === "MEMBER"
|
||||
? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen."
|
||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
<MenuItem value="">Ohne AG</MenuItem>
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
@@ -2453,16 +2706,15 @@ export function DashboardShell({
|
||||
onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })}
|
||||
fullWidth
|
||||
disabled={visibleGroups.length === 0}
|
||||
required={draft.role === "MEMBER"}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: draft.role === "MEMBER"
|
||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: draft.role === "MEMBER"
|
||||
? "Optional: AG-lose Mitglieder können sich einloggen, aber noch keine Ausgaben erfassen."
|
||||
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
<MenuItem value="">Ohne AG</MenuItem>
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
@@ -2780,6 +3032,167 @@ export function DashboardShell({
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const financeRows = (() => {
|
||||
if (financeViewMode === "monthly") {
|
||||
const rows = new Map<string, { label: string; planned: number; approved: number; paid: number; donations: number }>();
|
||||
for (const expense of allExpenses) {
|
||||
const date = new Date(expense.createdAt);
|
||||
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
const row = rows.get(key) ?? {
|
||||
label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date),
|
||||
planned: 0,
|
||||
approved: 0,
|
||||
paid: 0,
|
||||
donations: 0
|
||||
};
|
||||
if (expense.approvalStatus === "PENDING") row.planned += expense.netPeriodAmount;
|
||||
if (expense.approvalStatus === "APPROVED") row.approved += expense.netPeriodAmount;
|
||||
if (expense.paidAt) row.paid += expense.netPeriodAmount;
|
||||
rows.set(key, row);
|
||||
}
|
||||
for (const donation of donations) {
|
||||
const date = new Date(donation.donatedAt);
|
||||
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
const row = rows.get(key) ?? {
|
||||
label: new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(date),
|
||||
planned: 0,
|
||||
approved: 0,
|
||||
paid: 0,
|
||||
donations: 0
|
||||
};
|
||||
row.donations += donation.amount;
|
||||
rows.set(key, row);
|
||||
}
|
||||
return [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, row]) => row);
|
||||
}
|
||||
|
||||
if (financeViewMode === "cutoff") {
|
||||
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
||||
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
||||
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
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: currentPeriod?.name ?? "Jahresübersicht",
|
||||
planned: totals.pending,
|
||||
approved: totals.approved,
|
||||
paid: paidTotal,
|
||||
donations: generalDonationTotal + assignedDonationTotal
|
||||
}
|
||||
];
|
||||
})();
|
||||
|
||||
const financeOverviewContent = (
|
||||
<Stack spacing={2.5}>
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", md: "row" }} gap={1.2}>
|
||||
<TextField
|
||||
select
|
||||
label="Ansicht auswählen"
|
||||
value={financeViewMode}
|
||||
onChange={(event) => setFinanceViewMode(event.target.value as FinanceViewMode)}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="monthly">Monatsübersichten</MenuItem>
|
||||
<MenuItem value="yearly">Jahresübersicht</MenuItem>
|
||||
<MenuItem value="cutoff">Jahresübersicht Pre/Post</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Darstellung"
|
||||
value={financePresentation}
|
||||
onChange={(event) => setFinancePresentation(event.target.value as FinancePresentation)}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="charts">Grafisch</MenuItem>
|
||||
<MenuItem value="table">Tabellarisch</MenuItem>
|
||||
</TextField>
|
||||
</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={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
|
||||
<Chip label={`Netto-Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", lg: financePresentation === "charts" ? "repeat(3, minmax(0, 1fr))" : "1fr" },
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
{financeRows.map((row) => {
|
||||
const maxValue = Math.max(row.planned, row.approved, row.paid, row.donations, 1);
|
||||
return (
|
||||
<Card key={row.label} sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Stack spacing={1.4}>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
{(["planned", "approved", "paid", "donations"] as const).map((key) => {
|
||||
const label =
|
||||
key === "planned" ? "Geplant" : key === "approved" ? "Freigegeben" : key === "paid" ? "Bezahlt" : "Spenden";
|
||||
const value = row[key];
|
||||
return (
|
||||
<Box key={key}>
|
||||
<Stack direction="row" justifyContent="space-between" gap={1}>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{currencyFormatter.format(value)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{financePresentation === "charts" ? (
|
||||
<Box sx={{ height: 8, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.1), overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${Math.min((value / maxValue) * 100, 100)}%`,
|
||||
height: "100%",
|
||||
bgcolor: key === "donations" ? theme.palette.success.main : key === "paid" ? theme.palette.info.main : theme.palette.primary.main
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const desktopSectionContent =
|
||||
desktopSection === "overview" ? (
|
||||
<Stack
|
||||
@@ -2791,6 +3204,8 @@ export function DashboardShell({
|
||||
<Box sx={{ width: { xs: "100%", xl: 380 }, flexShrink: 0 }}>{actionCards}</Box>
|
||||
<Box sx={{ flex: "1 1 0%", minWidth: 0, maxWidth: "100%", overflowX: "hidden" }}>{overviewContent}</Box>
|
||||
</Stack>
|
||||
) : desktopSection === "finance" ? (
|
||||
financeOverviewContent
|
||||
) : desktopSection === "periods" ? (
|
||||
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
|
||||
{canManagePeriods ? (
|
||||
@@ -2902,6 +3317,10 @@ export function DashboardShell({
|
||||
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Ausgaben bis ${currentPeriod?.cutoffName ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
@@ -3002,11 +3421,12 @@ export function DashboardShell({
|
||||
onChange={(_, nextValue: MobileSection) => setMobileSection(nextValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab value="overview" label={"\u00dcbersicht"} />
|
||||
<Tab value="overview" label={"AG-\u00dcbersicht"} />
|
||||
<Tab value="finance" label={"Finanz\u00fcbersicht"} />
|
||||
<Tab value="actions" label="Aktionen" />
|
||||
</Tabs>
|
||||
</Card>
|
||||
{mobileSection === "overview" ? overviewContent : actionCards}
|
||||
{mobileSection === "overview" ? overviewContent : mobileSection === "finance" ? financeOverviewContent : actionCards}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ width: "100%" }}>{desktopSectionContent}</Box>
|
||||
|
||||
Reference in New Issue
Block a user