Eigene Ausgaben fuer Mitglieder bearbeitbar machen
CI / Build and Deploy (push) Successful in 3m18s

This commit is contained in:
jan
2026-08-22 15:27:06 +02:00
parent 35e8a62ded
commit 3feff2e38c
3 changed files with 68 additions and 32 deletions
+41 -14
View File
@@ -33,10 +33,6 @@ export async function PATCH(request: Request, { params }: Context) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
} }
if (!hasAdministrativeAccess(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Ausgaben bearbeiten." }, { status: 403 });
}
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
const parsed = updateExpenseSchema.safeParse(body); const parsed = updateExpenseSchema.safeParse(body);
@@ -52,6 +48,35 @@ export async function PATCH(request: Request, { params }: Context) {
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
} }
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[]>`
SELECT cutoff_id, cutoff_phase FROM expenses WHERE id = ${id}
`;
const previousCutoff = previousCutoffRows[0] ?? {
cutoff_id: null,
cutoff_phase: "PRE" as const
};
const isAdminUpdate = hasAdministrativeAccess(viewer.role);
const isOwnEditableExpense = viewer.id === expense.creatorId && !expense.paidAt && !expense.documentedAt;
if (!isAdminUpdate && !isOwnEditableExpense) {
return NextResponse.json(
{ error: "Du darfst nur eigene, noch nicht bezahlte oder dokumentierte Ausgaben bearbeiten." },
{ status: 403 }
);
}
if (!isAdminUpdate && parsed.data.agId !== expense.agId) {
return NextResponse.json({ error: "Mitglieder dürfen eigene Ausgaben nicht in eine andere AG verschieben." }, { status: 403 });
}
if (
!isAdminUpdate &&
((parsed.data.cutoffId ?? previousCutoff.cutoff_id) !== previousCutoff.cutoff_id ||
parsed.data.cutoffPhase !== previousCutoff.cutoff_phase)
) {
return NextResponse.json({ error: "Mitglieder dürfen die Stichtag-Zuordnung nicht ändern." }, { status: 403 });
}
const budget = await prisma.budget.findUnique({ const budget = await prisma.budget.findUnique({
where: { id: parsed.data.budgetId } where: { id: parsed.data.budgetId }
}); });
@@ -60,6 +85,10 @@ export async function PATCH(request: Request, { params }: Context) {
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 }); return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
} }
let cutoffId = previousCutoff.cutoff_id;
let cutoffPhase = previousCutoff.cutoff_phase;
if (isAdminUpdate) {
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>` const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM period_cutoffs SELECT id FROM period_cutoffs
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId} WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId}
@@ -72,18 +101,17 @@ export async function PATCH(request: Request, { params }: Context) {
ORDER BY date ASC NULLS LAST, created_at ASC ORDER BY date ASC NULLS LAST, created_at ASC
LIMIT 1 LIMIT 1
`; `;
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null; cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
cutoffPhase = parsed.data.cutoffPhase;
if (parsed.data.cutoffId && !cutoffId) { if (parsed.data.cutoffId && !cutoffId) {
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 }); return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
} }
}
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
SELECT cutoff_phase FROM expenses WHERE id = ${id}
`;
const previousSnapshot = snapshotExpense({ const previousSnapshot = snapshotExpense({
...expense, ...expense,
cutoffPhase: previousCutoffRows[0]?.cutoff_phase ?? "PRE" cutoffPhase: previousCutoff.cutoff_phase
}); });
const updatedExpense = await prisma.expense.update({ const updatedExpense = await prisma.expense.update({
@@ -98,7 +126,7 @@ export async function PATCH(request: Request, { params }: Context) {
}); });
await prisma.$executeRaw` await prisma.$executeRaw`
UPDATE expenses UPDATE expenses
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" SET cutoff_id = ${cutoffId}, cutoff_phase = ${cutoffPhase}::"CutoffPhase"
WHERE id = ${id} WHERE id = ${id}
`; `;
@@ -113,11 +141,11 @@ export async function PATCH(request: Request, { params }: Context) {
amount: Number(updatedExpense.amount), amount: Number(updatedExpense.amount),
budgetId: updatedExpense.budgetId, budgetId: updatedExpense.budgetId,
workingGroupId: updatedExpense.agId, workingGroupId: updatedExpense.agId,
cutoffPhase: parsed.data.cutoffPhase, cutoffPhase,
rollback: { rollback: {
kind: "expense.update", kind: "expense.update",
previous: previousSnapshot, previous: previousSnapshot,
next: snapshotExpense({ ...updatedExpense, cutoffPhase: parsed.data.cutoffPhase }) next: snapshotExpense({ ...updatedExpense, cutoffPhase })
} }
} }
}); });
@@ -147,13 +175,12 @@ export async function DELETE(_: Request, { params }: Context) {
const isAdminDelete = hasAdministrativeAccess(viewer.role); const isAdminDelete = hasAdministrativeAccess(viewer.role);
const isOwnPendingExpense = const isOwnPendingExpense =
viewer.id === expense.creatorId && viewer.id === expense.creatorId &&
expense.approvalStatus === "PENDING" &&
!expense.paidAt && !expense.paidAt &&
!expense.documentedAt; !expense.documentedAt;
if (!isAdminDelete && !isOwnPendingExpense) { if (!isAdminDelete && !isOwnPendingExpense) {
return NextResponse.json( return NextResponse.json(
{ error: "Du darfst nur eigene ungeprüfte Ausgaben löschen." }, { error: "Du darfst nur eigene, noch nicht bezahlte oder dokumentierte Ausgaben löschen." },
{ status: 403 } { status: 403 }
); );
} }
+12 -3
View File
@@ -308,7 +308,6 @@ export function BudgetColumn({
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap; group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372); const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
const canEditBudgets = canManageBudgets(viewer.role); const canEditBudgets = canManageBudgets(viewer.role);
const canEditExpenses = canManageBudgets(viewer.role);
const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE"; const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE";
useEffect(() => { useEffect(() => {
@@ -394,6 +393,14 @@ export function BudgetColumn({
})); }));
} }
function canEditExpense(expense: DashboardExpense) {
if (canManageBudgets(viewer.role)) {
return true;
}
return expense.creator.id === viewer.id && !expense.paidAt && !expense.documentedAt;
}
function getAssignedDonationDraft(expense: DashboardExpense, donation: DashboardExpenseDonation): AssignedDonationDraft { function getAssignedDonationDraft(expense: DashboardExpense, donation: DashboardExpenseDonation): AssignedDonationDraft {
return assignedDonationDrafts[donation.id] ?? { return assignedDonationDrafts[donation.id] ?? {
title: donation.title, title: donation.title,
@@ -1094,7 +1101,7 @@ export function BudgetColumn({
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"} {isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
</Button> </Button>
{canEditExpenses ? ( {canEditExpense(expense) ? (
<Button <Button
type="button" type="button"
size="small" size="small"
@@ -1118,6 +1125,7 @@ export function BudgetColumn({
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}> <Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
{(() => { {(() => {
const draft = getExpenseDraft(expense); const draft = getExpenseDraft(expense);
const canMoveExpense = canManageBudgets(viewer.role);
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;
@@ -1168,6 +1176,7 @@ export function BudgetColumn({
}); });
}} }}
fullWidth fullWidth
disabled={!canMoveExpense}
> >
{workingGroups.map((entry) => ( {workingGroups.map((entry) => (
<MenuItem key={entry.id} value={entry.id}> <MenuItem key={entry.id} value={entry.id}>
@@ -1200,7 +1209,7 @@ export function BudgetColumn({
updateExpenseDraft(expense, next); updateExpenseDraft(expense, next);
}} }}
fullWidth fullWidth
disabled={cutoffOptions.length === 0} disabled={cutoffOptions.length === 0 || !canMoveExpense}
> >
{cutoffOptions.map((option) => ( {cutoffOptions.map((option) => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} value={option.value}>
+1 -1
View File
@@ -106,7 +106,7 @@ export function canDeleteExpense(
return true; return true;
} }
return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt; return viewerId === creatorId && !paidAt && !documentedAt;
} }
export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] { export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {