Stichtag Timeline fuer Ausgaben und Finanzuebersicht
All checks were successful
CI / Build and Deploy (push) Successful in 2m58s

This commit is contained in:
jan
2026-05-12 01:53:57 +02:00
parent 5591d10d96
commit cee7081da6
2 changed files with 252 additions and 129 deletions

View File

@@ -51,6 +51,13 @@ import {
requiresManualApproval requiresManualApproval
} from "@/lib/domain"; } from "@/lib/domain";
type CutoffSelectionOption = {
value: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
label: string;
};
type BudgetColumnProps = { type BudgetColumnProps = {
group: DashboardWorkingGroup; group: DashboardWorkingGroup;
workingGroups: DashboardWorkingGroup[]; workingGroups: DashboardWorkingGroup[];
@@ -141,6 +148,42 @@ function createDraft(budget: DashboardBudget): BudgetDraft {
}; };
} }
function createCutoffSelectionValue(cutoffId: string, cutoffPhase: "PRE" | "POST") {
return `${cutoffId}:${cutoffPhase}`;
}
function parseCutoffSelectionValue(value: string) {
const [cutoffId, cutoffPhase] = value.split(":");
return {
cutoffId: cutoffId ?? "",
cutoffPhase: cutoffPhase === "POST" ? "POST" : "PRE"
} as const;
}
function getCutoffSelectionOptions(cutoffs: DashboardPeriodCutoff[]): CutoffSelectionOption[] {
if (cutoffs.length === 0) {
return [];
}
const options: CutoffSelectionOption[] = cutoffs.map((cutoff) => ({
value: createCutoffSelectionValue(cutoff.id, "PRE"),
cutoffId: cutoff.id,
cutoffPhase: "PRE" as const,
label: `Pre ${cutoff.name}`
}));
const lastCutoff = cutoffs[cutoffs.length - 1];
options.push({
value: createCutoffSelectionValue(lastCutoff.id, "POST"),
cutoffId: lastCutoff.id,
cutoffPhase: "POST" as const,
label: `Post ${lastCutoff.name}`
});
return options;
}
function StatusChips({ function StatusChips({
expense, expense,
canEditDonations, canEditDonations,
@@ -1078,8 +1121,12 @@ export function BudgetColumn({
const editGroup = const editGroup =
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group; workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
const editBudgets = editGroup.budgets; const editBudgets = editGroup.budgets;
const selectedCutoff = const cutoffOptions = getCutoffSelectionOptions(cutoffs);
cutoffs.find((cutoff) => cutoff.id === draft.cutoffId) ?? cutoffs[0] ?? null; const selectedCutoffValue = createCutoffSelectionValue(draft.cutoffId, draft.cutoffPhase);
const selectedCutoffOption =
cutoffOptions.find((option) => option.value === selectedCutoffValue) ??
cutoffOptions[0] ??
null;
return ( return (
<Stack spacing={1}> <Stack spacing={1}>
@@ -1143,38 +1190,24 @@ export function BudgetColumn({
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<Stack direction={{ xs: "column", sm: "row" }} gap={1}> <TextField
<TextField select
select label="Stichtag-Zuordnung"
label="Stichtag" size="small"
size="small" value={selectedCutoffOption?.value ?? ""}
value={draft.cutoffId} onChange={(event) => {
onChange={(event) => updateExpenseDraft(expense, { cutoffId: event.target.value })} const next = parseCutoffSelectionValue(event.target.value);
fullWidth updateExpenseDraft(expense, next);
disabled={cutoffs.length === 0} }}
> fullWidth
{cutoffs.map((cutoff) => ( disabled={cutoffOptions.length === 0}
<MenuItem key={cutoff.id} value={cutoff.id}> >
{cutoff.name} {cutoffOptions.map((option) => (
</MenuItem> <MenuItem key={option.value} value={option.value}>
))} {option.label}
</TextField> </MenuItem>
<TextField ))}
select </TextField>
label="Zuordnung"
size="small"
value={draft.cutoffPhase}
onChange={(event) =>
updateExpenseDraft(expense, {
cutoffPhase: event.target.value as "PRE" | "POST"
})
}
fullWidth
>
<MenuItem value="PRE">{`Pre ${selectedCutoff?.name ?? "Open Air"}`}</MenuItem>
<MenuItem value="POST">{`Post ${selectedCutoff?.name ?? "Open Air"}`}</MenuItem>
</TextField>
</Stack>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap"> <Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button <Button
type="button" type="button"
@@ -1182,7 +1215,11 @@ export function BudgetColumn({
variant="contained" variant="contained"
disabled={busy || !draft.budgetId} disabled={busy || !draft.budgetId}
onClick={async () => { onClick={async () => {
await onUpdateExpense(expense.id, draft); await onUpdateExpense(expense.id, {
...draft,
cutoffId: selectedCutoffOption?.cutoffId ?? draft.cutoffId,
cutoffPhase: selectedCutoffOption?.cutoffPhase ?? draft.cutoffPhase
});
setEditingExpenseId(null); setEditingExpenseId(null);
}} }}
> >

View File

@@ -46,6 +46,7 @@ import type {
DashboardAuditLog, DashboardAuditLog,
DashboardDonation, DashboardDonation,
DashboardManagedUser, DashboardManagedUser,
DashboardPeriodCutoff,
DashboardSettings, DashboardSettings,
DashboardViewer, DashboardViewer,
DashboardWorkingGroup DashboardWorkingGroup
@@ -223,6 +224,12 @@ type MobileAction =
type FinanceViewMode = "monthly" | "yearly" | "cutoff"; type FinanceViewMode = "monthly" | "yearly" | "cutoff";
type FinancePresentation = "charts" | "table"; type FinancePresentation = "charts" | "table";
type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs"; type DesktopSection = "overview" | "finance" | "budgetGroups" | "periods" | "users" | "logs";
type CutoffSelectionOption = {
value: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
label: string;
};
const currencyFormatter = new Intl.NumberFormat("de-DE", { const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency", style: "currency",
currency: "EUR" currency: "EUR"
@@ -241,6 +248,68 @@ function toDateInputValue(value: string) {
return value.slice(0, 10); return value.slice(0, 10);
} }
function createCutoffSelectionValue(cutoffId: string, cutoffPhase: "PRE" | "POST") {
return `${cutoffId}:${cutoffPhase}`;
}
function parseCutoffSelectionValue(value: string) {
const [cutoffId, cutoffPhase] = value.split(":");
return {
cutoffId: cutoffId ?? "",
cutoffPhase: cutoffPhase === "POST" ? "POST" : "PRE"
} as const;
}
function getCutoffSelectionOptions(cutoffs: DashboardPeriodCutoff[]): CutoffSelectionOption[] {
if (cutoffs.length === 0) {
return [];
}
const options: CutoffSelectionOption[] = cutoffs.map((cutoff) => ({
value: createCutoffSelectionValue(cutoff.id, "PRE"),
cutoffId: cutoff.id,
cutoffPhase: "PRE" as const,
label: `Pre ${cutoff.name}`
}));
const lastCutoff = cutoffs[cutoffs.length - 1];
options.push({
value: createCutoffSelectionValue(lastCutoff.id, "POST"),
cutoffId: lastCutoff.id,
cutoffPhase: "POST",
label: `Post ${lastCutoff.name}`
});
return options;
}
function getGeneralDonationCutoffSelectionValue(cutoffs: DashboardPeriodCutoff[], donatedAt: string) {
const options = getCutoffSelectionOptions(cutoffs);
if (options.length === 0) {
return "";
}
const donationDate = new Date(donatedAt);
donationDate.setHours(0, 0, 0, 0);
for (const cutoff of cutoffs) {
if (!cutoff.date) {
continue;
}
const cutoffDate = new Date(cutoff.date);
cutoffDate.setHours(0, 0, 0, 0);
if (donationDate <= cutoffDate) {
return createCutoffSelectionValue(cutoff.id, "PRE");
}
}
return createCutoffSelectionValue(cutoffs[cutoffs.length - 1].id, "POST");
}
function formatPeriodRange(startsAt: string, endsAt: string) { function formatPeriodRange(startsAt: string, endsAt: string) {
const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`; return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`;
@@ -809,8 +878,26 @@ export function DashboardShell({
const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? []; const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? [];
const currentCutoffs = currentPeriod?.cutoffs ?? []; const currentCutoffs = currentPeriod?.cutoffs ?? [];
const primaryCurrentCutoff = currentCutoffs[0] ?? null; const primaryCurrentCutoff = currentCutoffs[0] ?? null;
const selectedExpenseCutoff = const expenseCutoffOptions = useMemo(() => getCutoffSelectionOptions(currentCutoffs), [currentCutoffs]);
currentCutoffs.find((cutoff) => cutoff.id === expenseForm.cutoffId) ?? primaryCurrentCutoff; const selectedExpenseCutoffValue = createCutoffSelectionValue(expenseForm.cutoffId, expenseForm.cutoffPhase);
const selectedExpenseCutoffOption =
expenseCutoffOptions.find((option) => option.value === selectedExpenseCutoffValue) ?? expenseCutoffOptions[0] ?? null;
useEffect(() => {
if (expenseCutoffOptions.length === 0) {
return;
}
if (expenseCutoffOptions.some((option) => option.value === selectedExpenseCutoffValue)) {
return;
}
const fallback = expenseCutoffOptions[0];
setExpenseForm((current) => ({
...current,
cutoffId: fallback.cutoffId,
cutoffPhase: fallback.cutoffPhase
}));
}, [expenseCutoffOptions, selectedExpenseCutoffValue]);
const allExpenses = useMemo( const allExpenses = useMemo(
() => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)), () => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)),
@@ -938,19 +1025,35 @@ export function DashboardShell({
() => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0), () => donations.reduce((sum, donation) => sum + (donation.expenseId ? donation.amount : 0), 0),
[donations] [donations]
); );
const preCutoffExpenses = useMemo( const plannedUntilCutoffs = useMemo(
() => () => {
allExpenses.reduce( const today = new Date();
(sum, expense) => today.setHours(0, 0, 0, 0);
sum +
(expense.cutoffPhase === "PRE" && return currentCutoffs
!expense.paidAt && .filter((cutoff) => {
(!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id) if (!cutoff.date) {
? expense.netPeriodAmount return false;
: 0), }
0
), const cutoffDate = new Date(cutoff.date);
[allExpenses, primaryCurrentCutoff] cutoffDate.setHours(0, 0, 0, 0);
return cutoffDate >= today;
})
.map((cutoff) => ({
cutoff,
amount: allExpenses.reduce(
(sum, expense) =>
sum +
(expense.cutoffPhase === "PRE" && !expense.paidAt && expense.cutoffId === cutoff.id
? expense.netPeriodAmount
: 0),
0
)
}));
},
[allExpenses, currentCutoffs]
); );
const paidTotal = useMemo( const paidTotal = useMemo(
() => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0), () => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
@@ -2309,44 +2412,28 @@ export function DashboardShell({
helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."} helperText={"Ab diesem Datum werden Monatsraten innerhalb des aktuellen Zeitraums automatisch berechnet."}
/> />
) : null} ) : null}
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}> <TextField
<TextField select
select label="Stichtag-Zuordnung"
label="Stichtag" value={selectedExpenseCutoffOption?.value ?? ""}
value={expenseForm.cutoffId} onChange={(event) => {
onChange={(event) => const next = parseCutoffSelectionValue(event.target.value);
setExpenseForm((current) => ({ setExpenseForm((current) => ({
...current, ...current,
cutoffId: event.target.value cutoffId: next.cutoffId,
})) cutoffPhase: next.cutoffPhase
} }));
required }}
fullWidth required
disabled={currentCutoffs.length === 0} fullWidth
> disabled={expenseCutoffOptions.length === 0}
{currentCutoffs.map((cutoff) => ( >
<MenuItem key={cutoff.id} value={cutoff.id}> {expenseCutoffOptions.map((option) => (
{cutoff.name} <MenuItem key={option.value} value={option.value}>
</MenuItem> {option.label}
))} </MenuItem>
</TextField> ))}
<TextField </TextField>
select
label="Zuordnung"
value={expenseForm.cutoffPhase}
onChange={(event) =>
setExpenseForm((current) => ({
...current,
cutoffPhase: event.target.value as ExpenseFormState["cutoffPhase"]
}))
}
required
fullWidth
>
<MenuItem value="PRE">{`Pre ${selectedExpenseCutoff?.name ?? "Open Air"}`}</MenuItem>
<MenuItem value="POST">{`Post ${selectedExpenseCutoff?.name ?? "Open Air"}`}</MenuItem>
</TextField>
</Stack>
<TextField <TextField
select select
label="Arbeitsgruppe" label="Arbeitsgruppe"
@@ -3604,43 +3691,39 @@ export function DashboardShell({
} }
if (financeViewMode === "cutoff") { if (financeViewMode === "cutoff") {
const pre = allExpenses.filter( const expenseById = new Map(allExpenses.map((expense) => [expense.id, expense]));
(expense) => const rows = expenseCutoffOptions.map((option) => {
expense.cutoffPhase === "PRE" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id) const expenses = allExpenses.filter(
); (expense) => expense.cutoffId === option.cutoffId && expense.cutoffPhase === option.cutoffPhase
const post = allExpenses.filter( );
(expense) => const donationsForSection = donations.filter((donation) => {
expense.cutoffPhase === "POST" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id) if (donation.expenseId) {
); const expense = expenseById.get(donation.expenseId);
const cutoffDate = primaryCurrentCutoff?.date ? new Date(primaryCurrentCutoff.date) : null; return (
const preGeneralDonations = donations expense?.cutoffId === option.cutoffId &&
.filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate)) expense.cutoffPhase === option.cutoffPhase
.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); return getGeneralDonationCutoffSelectionValue(currentCutoffs, donation.donatedAt) === option.value;
const preAssignedDonations = donations });
.filter((donation) => donation.expenseId && pre.some((expense) => expense.id === donation.expenseId))
.reduce((sum, donation) => sum + donation.amount, 0); return {
const postAssignedDonations = donations label: option.label,
.filter((donation) => donation.expenseId && post.some((expense) => expense.id === donation.expenseId)) planned: expenses.reduce(
.reduce((sum, donation) => sum + donation.amount, 0); (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0),
return [ 0
{ ),
label: `Pre ${primaryCurrentCutoff?.name ?? "Open Air"}`, approved: expenses.reduce(
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0), (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0),
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0), 0
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0), ),
donations: preAssignedDonations + preGeneralDonations paid: expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
}, donations: donationsForSection.reduce((sum, donation) => sum + donation.amount, 0)
{ };
label: `Post ${primaryCurrentCutoff?.name ?? "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), return rows.length > 0 ? rows : [];
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
donations: postAssignedDonations + postGeneralDonations
}
];
} }
return [ return [
@@ -3901,10 +3984,13 @@ export function DashboardShell({
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`} label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/> />
<Chip {plannedUntilCutoffs.map(({ cutoff, amount }) => (
label={`Geplant bis ${primaryCurrentCutoff?.name ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`} <Chip
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} key={cutoff.id}
/> label={`Geplant bis ${cutoff.name}: ${currencyFormatter.format(amount)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
))}
<Chip <Chip
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`} label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}