This commit is contained in:
@@ -33,10 +33,6 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
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 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 });
|
||||
}
|
||||
|
||||
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({
|
||||
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 });
|
||||
}
|
||||
|
||||
let cutoffId = previousCutoff.cutoff_id;
|
||||
let cutoffPhase = previousCutoff.cutoff_phase;
|
||||
|
||||
if (isAdminUpdate) {
|
||||
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM period_cutoffs
|
||||
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
|
||||
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) {
|
||||
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({
|
||||
...expense,
|
||||
cutoffPhase: previousCutoffRows[0]?.cutoff_phase ?? "PRE"
|
||||
cutoffPhase: previousCutoff.cutoff_phase
|
||||
});
|
||||
|
||||
const updatedExpense = await prisma.expense.update({
|
||||
@@ -98,7 +126,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE expenses
|
||||
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
|
||||
SET cutoff_id = ${cutoffId}, cutoff_phase = ${cutoffPhase}::"CutoffPhase"
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
@@ -113,11 +141,11 @@ export async function PATCH(request: Request, { params }: Context) {
|
||||
amount: Number(updatedExpense.amount),
|
||||
budgetId: updatedExpense.budgetId,
|
||||
workingGroupId: updatedExpense.agId,
|
||||
cutoffPhase: parsed.data.cutoffPhase,
|
||||
cutoffPhase,
|
||||
rollback: {
|
||||
kind: "expense.update",
|
||||
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 isOwnPendingExpense =
|
||||
viewer.id === expense.creatorId &&
|
||||
expense.approvalStatus === "PENDING" &&
|
||||
!expense.paidAt &&
|
||||
!expense.documentedAt;
|
||||
|
||||
if (!isAdminDelete && !isOwnPendingExpense) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,7 +308,6 @@ export function BudgetColumn({
|
||||
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
|
||||
const canEditBudgets = canManageBudgets(viewer.role);
|
||||
const canEditExpenses = canManageBudgets(viewer.role);
|
||||
const canEditDonations = viewer.role === "ORGA" || viewer.role === "FINANCE";
|
||||
|
||||
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 {
|
||||
return assignedDonationDrafts[donation.id] ?? {
|
||||
title: donation.title,
|
||||
@@ -1094,7 +1101,7 @@ export function BudgetColumn({
|
||||
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||
</Button>
|
||||
|
||||
{canEditExpenses ? (
|
||||
{canEditExpense(expense) ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
@@ -1118,6 +1125,7 @@ export function BudgetColumn({
|
||||
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
|
||||
{(() => {
|
||||
const draft = getExpenseDraft(expense);
|
||||
const canMoveExpense = canManageBudgets(viewer.role);
|
||||
const editGroup =
|
||||
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||
const editBudgets = editGroup.budgets;
|
||||
@@ -1168,6 +1176,7 @@ export function BudgetColumn({
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
disabled={!canMoveExpense}
|
||||
>
|
||||
{workingGroups.map((entry) => (
|
||||
<MenuItem key={entry.id} value={entry.id}>
|
||||
@@ -1200,7 +1209,7 @@ export function BudgetColumn({
|
||||
updateExpenseDraft(expense, next);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={cutoffOptions.length === 0}
|
||||
disabled={cutoffOptions.length === 0 || !canMoveExpense}
|
||||
>
|
||||
{cutoffOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ export function canDeleteExpense(
|
||||
return true;
|
||||
}
|
||||
|
||||
return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt;
|
||||
return viewerId === creatorId && !paidAt && !documentedAt;
|
||||
}
|
||||
|
||||
export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
|
||||
|
||||
Reference in New Issue
Block a user