Mehrere Stichtage pro Zeitraum verwalten
CI / Build and Deploy (push) Successful in 2m43s

This commit is contained in:
jan
2026-05-12 01:37:28 +02:00
parent 08df13c044
commit 5591d10d96
11 changed files with 790 additions and 88 deletions
+51 -16
View File
@@ -36,6 +36,7 @@ import type {
DashboardBudget,
DashboardExpense,
DashboardExpenseDonation,
DashboardPeriodCutoff,
DashboardViewer,
DashboardWorkingGroup
} from "@/lib/dashboard-types";
@@ -53,6 +54,7 @@ import {
type BudgetColumnProps = {
group: DashboardWorkingGroup;
workingGroups: DashboardWorkingGroup[];
cutoffs: DashboardPeriodCutoff[];
viewer: DashboardViewer;
busy: boolean;
approvalThreshold: number;
@@ -75,6 +77,7 @@ type BudgetColumnProps = {
amount: string;
agId: string;
budgetId: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
}
) => Promise<void>;
@@ -208,6 +211,7 @@ function getPaidSpend(expenses: DashboardExpense[]) {
export function BudgetColumn({
group,
workingGroups,
cutoffs,
viewer,
busy,
approvalThreshold,
@@ -240,7 +244,18 @@ export function BudgetColumn({
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
const [editingAssignedDonationId, setEditingAssignedDonationId] = useState<string | null>(null);
const [expenseDrafts, setExpenseDrafts] = useState<
Record<string, { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffPhase: "PRE" | "POST" }>
Record<
string,
{
title: string;
description: string;
amount: string;
agId: string;
budgetId: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
}
>
>({});
const [assignedDonationDrafts, setAssignedDonationDrafts] = useState<Record<string, AssignedDonationDraft>>({});
@@ -321,6 +336,7 @@ export function BudgetColumn({
amount: expense.amount.toFixed(2),
agId: group.id,
budgetId: expense.budgetId,
cutoffId: expense.cutoffId ?? cutoffs[0]?.id ?? "",
cutoffPhase: expense.cutoffPhase
};
}
@@ -1062,6 +1078,8 @@ 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;
return (
<Stack spacing={1}>
@@ -1125,21 +1143,38 @@ export function BudgetColumn({
</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={{ xs: "column", sm: "row" }} gap={1}>
<TextField
select
label="Stichtag"
size="small"
value={draft.cutoffId}
onChange={(event) => updateExpenseDraft(expense, { cutoffId: event.target.value })}
fullWidth
disabled={cutoffs.length === 0}
>
{cutoffs.map((cutoff) => (
<MenuItem key={cutoff.id} value={cutoff.id}>
{cutoff.name}
</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"
+304 -61
View File
@@ -78,6 +78,7 @@ type ExpenseFormState = {
budgetId: string;
recurrence: "NONE" | "MONTHLY";
recurrenceStartAt: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
};
@@ -112,9 +113,17 @@ type ExpenseEditDraft = {
amount: string;
agId: string;
budgetId: string;
cutoffId: string;
cutoffPhase: "PRE" | "POST";
};
type CutoffFormState = {
name: string;
date: string;
};
type CutoffDraft = CutoffFormState;
type WorkingGroupFormState = {
name: string;
};
@@ -224,6 +233,10 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
timeStyle: "short"
});
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium"
});
function toDateInputValue(value: string) {
return value.slice(0, 10);
}
@@ -391,6 +404,7 @@ export function DashboardShell({
budgetId: defaultBudget?.id ?? "",
recurrence: "NONE",
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
cutoffId: currentPeriod?.cutoffs[0]?.id ?? "",
cutoffPhase: "PRE"
});
const [donationForm, setDonationForm] = useState<DonationFormState>({
@@ -450,6 +464,12 @@ export function DashboardShell({
});
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [cutoffForm, setCutoffForm] = useState<CutoffFormState>({
name: "Open Air",
date: ""
});
const [cutoffDrafts, setCutoffDrafts] = useState<Record<string, CutoffDraft>>({});
const [editingCutoffId, setEditingCutoffId] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
const handledDeepLinkRef = useRef<string | null>(null);
const busyRef = useRef(busy);
@@ -749,10 +769,19 @@ export function DashboardShell({
setEditingUserId(null);
}
}, [editingUserId, managedUsersState]);
const selectedExpenseGroup =
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
const selectedBudgetWorkingGroup =
useEffect(() => {
const cutoffs = currentPeriod?.cutoffs ?? [];
if (cutoffs.length > 0 && !cutoffs.some((cutoff) => cutoff.id === expenseForm.cutoffId)) {
setExpenseForm((current) => ({
...current,
cutoffId: cutoffs[0]?.id ?? ""
}));
}
}, [currentPeriod, expenseForm.cutoffId]);
const selectedExpenseGroup =
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
const selectedBudgetWorkingGroup =
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
const selectedBudgetReleaseGroup =
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
@@ -776,9 +805,12 @@ export function DashboardShell({
selectedPeriodForManagement !== null &&
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt) ||
periodEditForm.cutoffName.trim() !== selectedPeriodForManagement.cutoffName ||
periodEditForm.cutoffDate !== (selectedPeriodForManagement.cutoffDate ? toDateInputValue(selectedPeriodForManagement.cutoffDate) : ""));
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
const managementCutoffs = selectedPeriodForManagement?.cutoffs ?? [];
const currentCutoffs = currentPeriod?.cutoffs ?? [];
const primaryCurrentCutoff = currentCutoffs[0] ?? null;
const selectedExpenseCutoff =
currentCutoffs.find((cutoff) => cutoff.id === expenseForm.cutoffId) ?? primaryCurrentCutoff;
const allExpenses = useMemo(
() => visibleGroups.flatMap((group) => group.budgets.flatMap((budget) => budget.expenses)),
@@ -812,6 +844,23 @@ export function DashboardShell({
}));
}
function getCutoffDraft(cutoff: { id: string; name: string; date: string | null }): CutoffDraft {
return cutoffDrafts[cutoff.id] ?? {
name: cutoff.name,
date: cutoff.date ? toDateInputValue(cutoff.date) : ""
};
}
function updateCutoffDraft(cutoff: { id: string; name: string; date: string | null }, patch: Partial<CutoffDraft>) {
setCutoffDrafts((current) => ({
...current,
[cutoff.id]: {
...getCutoffDraft(cutoff),
...patch
}
}));
}
function getDonationDraft(donation: DashboardDonation): DonationDraft {
return donationDrafts[donation.id] ?? {
title: donation.title,
@@ -893,10 +942,15 @@ export function DashboardShell({
() =>
allExpenses.reduce(
(sum, expense) =>
sum + (expense.cutoffPhase === "PRE" && !expense.paidAt ? expense.netPeriodAmount : 0),
sum +
(expense.cutoffPhase === "PRE" &&
!expense.paidAt &&
(!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
? expense.netPeriodAmount
: 0),
0
),
[allExpenses]
[allExpenses, primaryCurrentCutoff]
);
const paidTotal = useMemo(
() => allExpenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
@@ -960,6 +1014,7 @@ export function DashboardShell({
budgetId: expenseForm.budgetId,
recurrence: expenseForm.recurrence,
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
cutoffId: expenseForm.cutoffId,
cutoffPhase: expenseForm.cutoffPhase
})
})
@@ -976,6 +1031,7 @@ export function DashboardShell({
budgetId: resetBudget,
recurrence: "NONE",
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
cutoffId: currentPeriod?.cutoffs[0]?.id ?? "",
cutoffPhase: "PRE"
});
}, "Ausgabe wurde gespeichert.");
@@ -1271,6 +1327,56 @@ export function DashboardShell({
}, `Spende ${title} wurde gelöscht.`);
}
async function handleCreateCutoff(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedPeriodForManagement) {
setMessage({ type: "error", text: "Bitte zuerst einen Zeitraum auswählen." });
return;
}
await runAction(async () => {
await parseResponse(
await fetch(`/api/periods/${selectedPeriodForManagement.id}/cutoffs`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(cutoffForm)
})
);
setCutoffForm({
name: "Open Air",
date: ""
});
}, "Stichtag wurde angelegt.");
}
async function handleUpdateCutoff(cutoffId: string, draft: CutoffDraft) {
await runAction(async () => {
await parseResponse(
await fetch(`/api/period-cutoffs/${cutoffId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(draft)
})
);
setEditingCutoffId(null);
}, "Stichtag wurde bearbeitet.");
}
async function handleDeleteCutoff(cutoffId: string, cutoffName: string) {
await runAction(async () => {
await parseResponse(
await fetch(`/api/period-cutoffs/${cutoffId}`, {
method: "DELETE"
})
);
}, `Stichtag ${cutoffName} wurde gelöscht.`);
}
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -1302,7 +1408,11 @@ export function DashboardShell({
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(periodEditForm)
body: JSON.stringify({
name: periodEditForm.name,
startsAt: periodEditForm.startsAt,
endsAt: periodEditForm.endsAt
})
})
);
}, `Zeitraum ${periodEditForm.name.trim() || selectedPeriodForManagement.name} wurde aktualisiert.`);
@@ -1950,41 +2060,144 @@ export function DashboardShell({
) : null;
const cutoffManagementPanel = canManagePeriods ? (
<Box component="form" onSubmit={handleSavePeriod}>
<Stack spacing={1.4}>
<Stack spacing={1.6}>
<Stack spacing={0.6}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Stichtage
</Typography>
<Typography variant="body2" color="text.secondary">
Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten.
Stichtage für Pre/Post-Auswertungen anlegen, bearbeiten und löschen.
</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>
<Stack spacing={1.2}>
{managementCutoffs.length > 0 ? (
managementCutoffs.map((cutoff) => {
const draft = getCutoffDraft(cutoff);
const isEditing = editingCutoffId === cutoff.id;
return (
<Box key={cutoff.id} sx={nestedPanelSx}>
{isEditing ? (
<Stack spacing={1.2}>
<TextField
label="Stichtag-Name"
value={draft.name}
onChange={(event) => updateCutoffDraft(cutoff, { name: event.target.value })}
required
fullWidth
/>
<TextField
label="Datum"
type="date"
value={draft.date}
onChange={(event) => updateCutoffDraft(cutoff, { date: event.target.value })}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
variant="contained"
size="small"
disabled={busy || draft.name.trim().length === 0}
onClick={() => handleUpdateCutoff(cutoff.id, draft)}
>
Speichern
</Button>
<Button
type="button"
variant="text"
size="small"
disabled={busy}
onClick={() => setEditingCutoffId(null)}
>
Abbrechen
</Button>
</Stack>
</Stack>
) : (
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" gap={1.2}>
<Box>
<Typography sx={{ fontWeight: 700 }}>{cutoff.name}</Typography>
<Typography variant="body2" color="text.secondary">
{cutoff.date ? dateFormatter.format(new Date(cutoff.date)) : "Kein Datum gesetzt"}
</Typography>
</Box>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
type="button"
variant="outlined"
size="small"
startIcon={<EditRoundedIcon />}
disabled={busy}
onClick={() => {
setEditingCutoffId(cutoff.id);
setCutoffDrafts((current) => ({
...current,
[cutoff.id]: getCutoffDraft(cutoff)
}));
}}
>
Bearbeiten
</Button>
<Button
type="button"
variant="outlined"
color="error"
size="small"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy || managementCutoffs.length <= 1}
onClick={() => {
if (!window.confirm(`Stichtag "${cutoff.name}" wirklich löschen?`)) {
return;
}
handleDeleteCutoff(cutoff.id, cutoff.name);
}}
>
Löschen
</Button>
</Stack>
</Stack>
)}
</Box>
);
})
) : (
<Typography variant="body2" color="text.secondary">
Für diesen Zeitraum gibt es noch keine Stichtage.
</Typography>
)}
</Stack>
<Box component="form" onSubmit={handleCreateCutoff} sx={nestedPanelSx}>
<Stack spacing={1.2}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Neuen Stichtag anlegen
</Typography>
<TextField
label="Stichtag-Name"
value={cutoffForm.name}
onChange={(event) => setCutoffForm((current) => ({ ...current, name: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Datum"
type="date"
value={cutoffForm.date}
onChange={(event) => setCutoffForm((current) => ({ ...current, date: event.target.value }))}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!selectedPeriodForManagement}
/>
<Button type="submit" variant="outlined" disabled={busy || !selectedPeriodForManagement}>
Stichtag anlegen
</Button>
</Stack>
</Box>
</Stack>
) : null;
const actionCards = (
<Stack
@@ -2096,22 +2309,44 @@ 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>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
<TextField
select
label="Stichtag"
value={expenseForm.cutoffId}
onChange={(event) =>
setExpenseForm((current) => ({
...current,
cutoffId: event.target.value
}))
}
required
fullWidth
disabled={currentCutoffs.length === 0}
>
{currentCutoffs.map((cutoff) => (
<MenuItem key={cutoff.id} value={cutoff.id}>
{cutoff.name}
</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"
@@ -3250,6 +3485,7 @@ export function DashboardShell({
<BudgetColumn
group={group}
workingGroups={visibleGroups}
cutoffs={currentCutoffs}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
@@ -3302,6 +3538,7 @@ export function DashboardShell({
<BudgetColumn
group={group}
workingGroups={visibleGroups}
cutoffs={currentCutoffs}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
@@ -3367,9 +3604,15 @@ 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 pre = allExpenses.filter(
(expense) =>
expense.cutoffPhase === "PRE" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
);
const post = allExpenses.filter(
(expense) =>
expense.cutoffPhase === "POST" && (!primaryCurrentCutoff?.id || expense.cutoffId === primaryCurrentCutoff.id)
);
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);
@@ -3384,14 +3627,14 @@ export function DashboardShell({
.reduce((sum, donation) => sum + donation.amount, 0);
return [
{
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
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 ${currentPeriod?.cutoffName ?? "Open Air"}`,
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),
@@ -3659,7 +3902,7 @@ export function DashboardShell({
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
<Chip
label={`Geplant bis ${currentPeriod?.cutoffName ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
label={`Geplant bis ${primaryCurrentCutoff?.name ?? "Open Air"}: ${currencyFormatter.format(preCutoffExpenses)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }}
/>
<Chip