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 });
|
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,30 +85,33 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
|
let cutoffId = previousCutoff.cutoff_id;
|
||||||
SELECT id FROM period_cutoffs
|
let cutoffPhase = previousCutoff.cutoff_phase;
|
||||||
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId}
|
|
||||||
`;
|
|
||||||
const fallbackCutoffRows = parsed.data.cutoffId
|
|
||||||
? []
|
|
||||||
: await prisma.$queryRaw<{ id: string }[]>`
|
|
||||||
SELECT id FROM period_cutoffs
|
|
||||||
WHERE period_id = ${expense.periodId}
|
|
||||||
ORDER BY date ASC NULLS LAST, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
|
|
||||||
|
|
||||||
if (parsed.data.cutoffId && !cutoffId) {
|
if (isAdminUpdate) {
|
||||||
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
|
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
|
||||||
|
SELECT id FROM period_cutoffs
|
||||||
|
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId}
|
||||||
|
`;
|
||||||
|
const fallbackCutoffRows = parsed.data.cutoffId
|
||||||
|
? []
|
||||||
|
: await prisma.$queryRaw<{ id: string }[]>`
|
||||||
|
SELECT id FROM period_cutoffs
|
||||||
|
WHERE period_id = ${expense.periodId}
|
||||||
|
ORDER BY date ASC NULLS LAST, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
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({
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user