Stichtag Timeline fuer Ausgaben und Finanzuebersicht
All checks were successful
CI / Build and Deploy (push) Successful in 2m58s
All checks were successful
CI / Build and Deploy (push) Successful in 2m58s
This commit is contained in:
@@ -51,6 +51,13 @@ import {
|
||||
requiresManualApproval
|
||||
} from "@/lib/domain";
|
||||
|
||||
type CutoffSelectionOption = {
|
||||
value: string;
|
||||
cutoffId: string;
|
||||
cutoffPhase: "PRE" | "POST";
|
||||
label: string;
|
||||
};
|
||||
|
||||
type BudgetColumnProps = {
|
||||
group: 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({
|
||||
expense,
|
||||
canEditDonations,
|
||||
@@ -1078,8 +1121,12 @@ export function BudgetColumn({
|
||||
const editGroup =
|
||||
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||
const editBudgets = editGroup.budgets;
|
||||
const selectedCutoff =
|
||||
cutoffs.find((cutoff) => cutoff.id === draft.cutoffId) ?? cutoffs[0] ?? null;
|
||||
const cutoffOptions = getCutoffSelectionOptions(cutoffs);
|
||||
const selectedCutoffValue = createCutoffSelectionValue(draft.cutoffId, draft.cutoffPhase);
|
||||
const selectedCutoffOption =
|
||||
cutoffOptions.find((option) => option.value === selectedCutoffValue) ??
|
||||
cutoffOptions[0] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
@@ -1143,38 +1190,24 @@ export function BudgetColumn({
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
label="Stichtag-Zuordnung"
|
||||
size="small"
|
||||
value={draft.cutoffId}
|
||||
onChange={(event) => updateExpenseDraft(expense, { cutoffId: event.target.value })}
|
||||
value={selectedCutoffOption?.value ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = parseCutoffSelectionValue(event.target.value);
|
||||
updateExpenseDraft(expense, next);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={cutoffs.length === 0}
|
||||
disabled={cutoffOptions.length === 0}
|
||||
>
|
||||
{cutoffs.map((cutoff) => (
|
||||
<MenuItem key={cutoff.id} value={cutoff.id}>
|
||||
{cutoff.name}
|
||||
{cutoffOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1182,7 +1215,11 @@ export function BudgetColumn({
|
||||
variant="contained"
|
||||
disabled={busy || !draft.budgetId}
|
||||
onClick={async () => {
|
||||
await onUpdateExpense(expense.id, draft);
|
||||
await onUpdateExpense(expense.id, {
|
||||
...draft,
|
||||
cutoffId: selectedCutoffOption?.cutoffId ?? draft.cutoffId,
|
||||
cutoffPhase: selectedCutoffOption?.cutoffPhase ?? draft.cutoffPhase
|
||||
});
|
||||
setEditingExpenseId(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
DashboardAuditLog,
|
||||
DashboardDonation,
|
||||
DashboardManagedUser,
|
||||
DashboardPeriodCutoff,
|
||||
DashboardSettings,
|
||||
DashboardViewer,
|
||||
DashboardWorkingGroup
|
||||
@@ -223,6 +224,12 @@ type MobileAction =
|
||||
type FinanceViewMode = "monthly" | "yearly" | "cutoff";
|
||||
type FinancePresentation = "charts" | "table";
|
||||
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", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
@@ -241,6 +248,68 @@ function toDateInputValue(value: string) {
|
||||
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) {
|
||||
const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
||||
return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`;
|
||||
@@ -809,8 +878,26 @@ export function DashboardShell({
|
||||
const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? [];
|
||||
const currentCutoffs = currentPeriod?.cutoffs ?? [];
|
||||
const primaryCurrentCutoff = currentCutoffs[0] ?? null;
|
||||
const selectedExpenseCutoff =
|
||||
currentCutoffs.find((cutoff) => cutoff.id === expenseForm.cutoffId) ?? primaryCurrentCutoff;
|
||||
const expenseCutoffOptions = useMemo(() => getCutoffSelectionOptions(currentCutoffs), [currentCutoffs]);
|
||||
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(
|
||||
() => 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]
|
||||
);
|
||||
const preCutoffExpenses = useMemo(
|
||||
() =>
|
||||
allExpenses.reduce(
|
||||
const plannedUntilCutoffs = useMemo(
|
||||
() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return currentCutoffs
|
||||
.filter((cutoff) => {
|
||||
if (!cutoff.date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(cutoff.date);
|
||||
cutoffDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return cutoffDate >= today;
|
||||
})
|
||||
.map((cutoff) => ({
|
||||
cutoff,
|
||||
amount: allExpenses.reduce(
|
||||
(sum, expense) =>
|
||||
sum +
|
||||
(expense.cutoffPhase === "PRE" &&
|
||||
!expense.paidAt &&
|
||||
(!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
(expense.cutoffPhase === "PRE" && !expense.paidAt && expense.cutoffId === cutoff.id
|
||||
? expense.netPeriodAmount
|
||||
: 0),
|
||||
0
|
||||
),
|
||||
[allExpenses, primaryCurrentCutoff]
|
||||
)
|
||||
}));
|
||||
},
|
||||
[allExpenses, currentCutoffs]
|
||||
);
|
||||
const paidTotal = useMemo(
|
||||
() => 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."}
|
||||
/>
|
||||
) : null}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
|
||||
<TextField
|
||||
select
|
||||
label="Stichtag"
|
||||
value={expenseForm.cutoffId}
|
||||
onChange={(event) =>
|
||||
label="Stichtag-Zuordnung"
|
||||
value={selectedExpenseCutoffOption?.value ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = parseCutoffSelectionValue(event.target.value);
|
||||
setExpenseForm((current) => ({
|
||||
...current,
|
||||
cutoffId: event.target.value
|
||||
}))
|
||||
}
|
||||
cutoffId: next.cutoffId,
|
||||
cutoffPhase: next.cutoffPhase
|
||||
}));
|
||||
}}
|
||||
required
|
||||
fullWidth
|
||||
disabled={currentCutoffs.length === 0}
|
||||
disabled={expenseCutoffOptions.length === 0}
|
||||
>
|
||||
{currentCutoffs.map((cutoff) => (
|
||||
<MenuItem key={cutoff.id} value={cutoff.id}>
|
||||
{cutoff.name}
|
||||
{expenseCutoffOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</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
|
||||
select
|
||||
label="Arbeitsgruppe"
|
||||
@@ -3604,43 +3691,39 @@ export function DashboardShell({
|
||||
}
|
||||
|
||||
if (financeViewMode === "cutoff") {
|
||||
const pre = allExpenses.filter(
|
||||
(expense) =>
|
||||
expense.cutoffPhase === "PRE" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
const expenseById = new Map(allExpenses.map((expense) => [expense.id, expense]));
|
||||
const rows = expenseCutoffOptions.map((option) => {
|
||||
const expenses = allExpenses.filter(
|
||||
(expense) => expense.cutoffId === option.cutoffId && expense.cutoffPhase === option.cutoffPhase
|
||||
);
|
||||
const post = allExpenses.filter(
|
||||
(expense) =>
|
||||
expense.cutoffPhase === "POST" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
|
||||
const donationsForSection = donations.filter((donation) => {
|
||||
if (donation.expenseId) {
|
||||
const expense = expenseById.get(donation.expenseId);
|
||||
return (
|
||||
expense?.cutoffId === option.cutoffId &&
|
||||
expense.cutoffPhase === option.cutoffPhase
|
||||
);
|
||||
const cutoffDate = primaryCurrentCutoff?.date ? new Date(primaryCurrentCutoff.date) : 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 ${primaryCurrentCutoff?.name ?? "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: preAssignedDonations + preGeneralDonations
|
||||
},
|
||||
{
|
||||
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),
|
||||
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
donations: postAssignedDonations + postGeneralDonations
|
||||
}
|
||||
];
|
||||
|
||||
return getGeneralDonationCutoffSelectionValue(currentCutoffs, donation.donatedAt) === option.value;
|
||||
});
|
||||
|
||||
return {
|
||||
label: option.label,
|
||||
planned: expenses.reduce(
|
||||
(sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0),
|
||||
0
|
||||
),
|
||||
approved: expenses.reduce(
|
||||
(sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0),
|
||||
0
|
||||
),
|
||||
paid: expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||
donations: donationsForSection.reduce((sum, donation) => sum + donation.amount, 0)
|
||||
};
|
||||
});
|
||||
|
||||
return rows.length > 0 ? rows : [];
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -3901,10 +3984,13 @@ export function DashboardShell({
|
||||
label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
{plannedUntilCutoffs.map(({ cutoff, amount }) => (
|
||||
<Chip
|
||||
label={`Geplant bis ${primaryCurrentCutoff?.name ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
|
||||
key={cutoff.id}
|
||||
label={`Geplant bis ${cutoff.name}: ${currencyFormatter.format(amount)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
/>
|
||||
))}
|
||||
<Chip
|
||||
label={`Abos monatlich: ${currencyFormatter.format(totals.subscriptions)}`}
|
||||
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
|
||||
|
||||
Reference in New Issue
Block a user