AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
CI / Build and Deploy (push) Successful in 2m20s

This commit is contained in:
jan
2026-05-05 21:57:20 +02:00
parent 99d4f6dd22
commit f87a82e02f
21 changed files with 885 additions and 323 deletions
+64 -29
View File
@@ -361,14 +361,24 @@ export async function POST(_: Request, { params }: Context) {
where: {
id: asString(previous.id, "Einstellungs-ID")
},
update: {
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle")
},
create: {
id: asString(previous.id, "Einstellungs-ID"),
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"),
createdAt: asDate(previous.createdAt, "Einstellungen erstellt am") ?? new Date()
}
update: {
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"),
requiredApprovalTypes: asApprovalPermissions(previous.requiredApprovalTypes),
budgetReleaseNotifyTarget:
asString(previous.budgetReleaseNotifyTarget ?? "ALL_GROUP_USERS", "Budget-Push-Ziel") as
| "ALL_GROUP_USERS"
| "GROUP_MEMBERS_ONLY"
},
create: {
id: asString(previous.id, "Einstellungs-ID"),
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"),
requiredApprovalTypes: asApprovalPermissions(previous.requiredApprovalTypes),
budgetReleaseNotifyTarget:
asString(previous.budgetReleaseNotifyTarget ?? "ALL_GROUP_USERS", "Budget-Push-Ziel") as
| "ALL_GROUP_USERS"
| "GROUP_MEMBERS_ONLY",
createdAt: asDate(previous.createdAt, "Einstellungen erstellt am") ?? new Date()
}
});
break;
}
@@ -500,10 +510,11 @@ export async function POST(_: Request, { params }: Context) {
break;
}
case "expense.delete": {
const deleted = asRecord(rollback.deleted, "Ausgabe");
case "expense.delete": {
const deleted = asRecord(rollback.deleted, "Ausgabe");
const deletedDocuments = Array.isArray(rollback.deletedDocuments) ? rollback.deletedDocuments : [];
await tx.expense.create({
await tx.expense.create({
data: {
id: asString(deleted.id, "Ausgabe-ID"),
title: asString(deleted.title, "Titel"),
@@ -516,15 +527,32 @@ export async function POST(_: Request, { params }: Context) {
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
invoiceDate: asDate(deleted.invoiceDate, "Rechnungsdatum"),
proofUrl: asNullableString(deleted.proofUrl),
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
documentedAt: asDate(deleted.documentedAt, "Dokumentiert am")
}
});
break;
}
documentedAt: asDate(deleted.documentedAt, "Dokumentiert am")
}
});
for (const deletedDocumentValue of deletedDocuments) {
const deletedDocument = asRecord(deletedDocumentValue, "Rechnungsdokument");
await tx.expenseDocument.create({
data: {
id: asString(deletedDocument.id, "Dokument-ID"),
expenseId: asString(deletedDocument.expenseId, "Dokument-Ausgabe-ID"),
invoiceDate: asDate(deletedDocument.invoiceDate, "Rechnungsdatum") ?? new Date(),
proofUrl: asString(deletedDocument.proofUrl, "Beleg-Link"),
driveFileId: asNullableString(deletedDocument.driveFileId),
originalFileName: asString(deletedDocument.originalFileName, "Originaldateiname"),
storedFileName: asString(deletedDocument.storedFileName, "Ablagedateiname"),
mimeType: asString(deletedDocument.mimeType, "Dateityp"),
size: asNumber(deletedDocument.size, "Dateigröße"),
uploadedById: asString(deletedDocument.uploadedById, "Uploader-ID"),
createdAt: asDate(deletedDocument.createdAt, "Dokument erstellt am") ?? new Date()
}
});
}
break;
}
case "expense.approve": {
const approval = asRecord(rollback.approval, "Freigabe");
@@ -568,16 +596,23 @@ export async function POST(_: Request, { params }: Context) {
break;
}
case "expense.document": {
await tx.expense.update({
where: {
id: asString(rollback.expenseId, "Ausgabe-ID")
},
data: {
proofUrl: asNullableString(rollback.previousProofUrl),
invoiceDate: asDate(rollback.previousInvoiceDate, "Vorheriges Rechnungsdatum"),
documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt"),
paidAt: asDate(rollback.previousPaidAt, "Vorheriger Bezahlt-Zeitpunkt")
case "expense.document":
case "expense.document.create": {
if (rollback.documentId) {
await tx.expenseDocument.deleteMany({
where: {
id: asString(rollback.documentId, "Dokument-ID")
}
});
}
await tx.expense.update({
where: {
id: asString(rollback.expenseId, "Ausgabe-ID")
},
data: {
documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt"),
paidAt: asDate(rollback.previousPaidAt, "Vorheriger Bezahlt-Zeitpunkt")
}
});
break;
+26 -1
View File
@@ -2,10 +2,12 @@ import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getAppSettings } from "@/lib/app-settings";
import { snapshotBudget } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log";
import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { notifyBudgetRelease } from "@/lib/push-notifications";
import { getCurrentViewer } from "@/lib/session";
const updateBudgetSchema = z
@@ -44,7 +46,15 @@ export async function PATCH(request: Request, { params }: Context) {
}
const budget = await prisma.budget.findUnique({
where: { id }
where: { id },
include: {
workingGroup: {
select: {
id: true,
name: true
}
}
}
});
if (!budget) {
@@ -61,6 +71,7 @@ export async function PATCH(request: Request, { params }: Context) {
try {
const previousBudget = budget;
const nextReleasedAmount = parsed.data.releasedAmount ?? Number(previousBudget.releasedAmount);
const previousReleasedAmount = Number(previousBudget.releasedAmount);
const updatedBudget = await prisma.budget.update({
where: { id },
data: {
@@ -71,6 +82,20 @@ export async function PATCH(request: Request, { params }: Context) {
}
});
if (nextReleasedAmount > previousReleasedAmount) {
const appSettings = await getAppSettings();
await notifyBudgetRelease(
{
id: updatedBudget.id,
name: updatedBudget.name,
workingGroupId: budget.workingGroup.id,
workingGroupName: budget.workingGroup.name,
releasedAmount: nextReleasedAmount
},
appSettings.budgetReleaseNotifyTarget
);
}
await createAuditLog(prisma, {
actorId: viewer.id,
action: "budget.update",
+5 -2
View File
@@ -8,6 +8,7 @@ import {
APPROVAL_FLOW,
getAvailableApprovalTypes,
normalizeApprovalPermissions,
normalizeRequiredApprovalTypes,
requiresManualApproval
} from "@/lib/domain";
import prisma from "@/lib/prisma";
@@ -46,6 +47,7 @@ export async function POST(request: Request, { params }: Context) {
}
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
if (!requiresManualApproval(Number(expense.amount), approvalThreshold)) {
return NextResponse.json({ error: "Diese Ausgabe ist bereits automatisch freigegeben." }, { status: 400 });
@@ -64,7 +66,7 @@ export async function POST(request: Request, { params }: Context) {
viewer.approvalPermissions,
viewer.approvalPreference
);
const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals);
const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals, requiredApprovalTypes);
if (!availableApprovals.includes(parsed.data.approvalType)) {
return NextResponse.json({ error: "Du darfst diese Freigabe nicht setzen." }, { status: 403 });
@@ -97,7 +99,7 @@ export async function POST(request: Request, { params }: Context) {
});
const approvalTypes = approvals.map((approval) => approval.approvalType);
const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType))
const approvalStatus = requiredApprovalTypes.every((approvalType) => approvalTypes.includes(approvalType))
? "APPROVED"
: "PENDING";
@@ -125,6 +127,7 @@ export async function POST(request: Request, { params }: Context) {
metadata: {
approvalType: parsed.data.approvalType,
approvalThreshold,
requiredApprovalTypes,
rollback: {
kind: "expense.approve",
approval: snapshotApproval(transactionResult.approval),
+1 -19
View File
@@ -1,24 +1,17 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAuditLog } from "@/lib/audit-log";
import { canDocumentExpense } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
const documentedSchema = z.object({
proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined))
});
type Context = {
params: Promise<{
id: string;
}>;
};
export async function POST(request: Request, { params }: Context) {
export async function POST(_: Request, { params }: Context) {
const { id } = await params;
const viewer = await getCurrentViewer();
@@ -46,17 +39,9 @@ export async function POST(request: Request, { params }: Context) {
return NextResponse.json({ error: "Bitte zuerst Bezahlt setzen." }, { status: 400 });
}
const body = await request.json().catch(() => ({}));
const parsed = documentedSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Beleg-Link ist ungueltig." }, { status: 400 });
}
const updatedExpense = await prisma.expense.update({
where: { id },
data: {
proofUrl: parsed.data.proofUrl ?? expense.proofUrl,
documentedAt: expense.documentedAt ?? new Date()
}
});
@@ -69,13 +54,10 @@ export async function POST(request: Request, { params }: Context) {
entityLabel: updatedExpense.title,
summary: `Ausgabe ${updatedExpense.title} wurde dokumentiert.`,
metadata: {
proofUrl: parsed.data.proofUrl ?? updatedExpense.proofUrl,
rollback: {
kind: "expense.document",
expenseId: updatedExpense.id,
previousProofUrl: expense.proofUrl,
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
nextProofUrl: updatedExpense.proofUrl,
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null
}
}
+71 -24
View File
@@ -33,7 +33,14 @@ export async function POST(request: Request, { params }: Context) {
}
const expense = await prisma.expense.findUnique({
where: { id }
where: { id },
include: {
_count: {
select: {
documents: true
}
}
}
});
if (!expense) {
@@ -68,48 +75,88 @@ export async function POST(request: Request, { params }: Context) {
return NextResponse.json({ error: "Der Beleg darf maximal 12 MB gross sein." }, { status: 400 });
}
const proofUrl = await uploadExpenseProofToDrive({
const uploadedFile = await uploadExpenseProofToDrive({
title: expense.title,
invoiceDate: invoiceDate.toISOString().slice(0, 10),
sequence: expense._count.documents + 1,
fileName: file.name,
mimeType: file.type,
buffer: Buffer.from(await file.arrayBuffer())
});
const updatedExpense = await prisma.expense.update({
where: { id: expense.id },
data: {
proofUrl,
invoiceDate,
documentedAt: expense.documentedAt ?? new Date(),
paidAt: expense.paidAt ?? new Date()
}
const now = new Date();
const transactionResult = await prisma.$transaction(async (tx) => {
const document = await tx.expenseDocument.create({
data: {
expenseId: expense.id,
invoiceDate,
proofUrl: uploadedFile.proofUrl,
driveFileId: uploadedFile.driveFileId,
originalFileName: file.name,
storedFileName: uploadedFile.storedFileName,
mimeType: file.type,
size: file.size,
uploadedById: viewer.id
},
include: {
uploadedBy: {
select: {
id: true,
username: true
}
}
}
});
const updatedExpense = await tx.expense.update({
where: { id: expense.id },
data: {
documentedAt: expense.documentedAt ?? now,
paidAt: expense.paidAt ?? now
}
});
return { document, updatedExpense };
});
await createAuditLog(prisma, {
actorId: viewer.id,
action: "expense.document",
entityType: "expense",
entityId: updatedExpense.id,
entityLabel: updatedExpense.title,
summary: `Rechnung fuer ${updatedExpense.title} wurde abgegeben.`,
entityId: transactionResult.updatedExpense.id,
entityLabel: transactionResult.updatedExpense.title,
summary: `Rechnung fuer ${transactionResult.updatedExpense.title} wurde abgegeben.`,
metadata: {
proofUrl: updatedExpense.proofUrl,
invoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null,
documentId: transactionResult.document.id,
proofUrl: transactionResult.document.proofUrl,
invoiceDate: transactionResult.document.invoiceDate.toISOString(),
rollback: {
kind: "expense.document",
expenseId: updatedExpense.id,
previousProofUrl: expense.proofUrl,
previousInvoiceDate: expense.invoiceDate?.toISOString() ?? null,
kind: "expense.document.create",
expenseId: transactionResult.updatedExpense.id,
documentId: transactionResult.document.id,
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
nextProofUrl: updatedExpense.proofUrl,
nextInvoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null,
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null,
nextDocumentedAt: transactionResult.updatedExpense.documentedAt?.toISOString() ?? null,
previousPaidAt: expense.paidAt?.toISOString() ?? null,
nextPaidAt: updatedExpense.paidAt?.toISOString() ?? null
nextPaidAt: transactionResult.updatedExpense.paidAt?.toISOString() ?? null
}
}
});
return NextResponse.json({ proofUrl, expense: updatedExpense });
return NextResponse.json({
document: {
id: transactionResult.document.id,
invoiceDate: transactionResult.document.invoiceDate.toISOString(),
proofUrl: transactionResult.document.proofUrl,
storedFileName: transactionResult.document.storedFileName,
originalFileName: transactionResult.document.originalFileName,
mimeType: transactionResult.document.mimeType,
size: transactionResult.document.size,
createdAt: transactionResult.document.createdAt.toISOString(),
uploadedBy: {
id: transactionResult.document.uploadedBy.id,
name: transactionResult.document.uploadedBy.username
}
},
expense: transactionResult.updatedExpense
});
}
+18 -2
View File
@@ -21,7 +21,10 @@ export async function DELETE(_: Request, { params }: Context) {
}
const expense = await prisma.expense.findUnique({
where: { id }
where: { id },
include: {
documents: true
}
});
if (!expense) {
@@ -56,7 +59,20 @@ export async function DELETE(_: Request, { params }: Context) {
metadata: {
rollback: {
kind: "expense.delete",
deleted: snapshotExpense(expense)
deleted: snapshotExpense(expense),
deletedDocuments: expense.documents.map((document) => ({
id: document.id,
expenseId: document.expenseId,
invoiceDate: document.invoiceDate.toISOString(),
proofUrl: document.proofUrl,
driveFileId: document.driveFileId,
originalFileName: document.originalFileName,
storedFileName: document.storedFileName,
mimeType: document.mimeType,
size: document.size,
uploadedById: document.uploadedById,
createdAt: document.createdAt.toISOString()
}))
}
}
});
+5 -7
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
import { snapshotExpense } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log";
import { APPROVAL_FLOW, canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
import { canCreateExpenseForGroup, normalizeRequiredApprovalTypes, requiresManualApproval } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { notifyApprovalRequest } from "@/lib/push-notifications";
import { getCurrentViewer } from "@/lib/session";
@@ -40,11 +40,7 @@ const expenseSchema = z
}
return parseDateInput(value) ?? "invalid";
}),
proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
.optional()
.transform(() => undefined)
})
})
.superRefine((value, ctx) => {
if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) {
@@ -97,6 +93,7 @@ export async function POST(request: Request) {
}
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
const recurrenceStartAt =
parsed.data.recurrence === "MONTHLY" && parsed.data.recurrenceStartAt instanceof Date
? parsed.data.recurrenceStartAt
@@ -125,7 +122,7 @@ export async function POST(request: Request) {
title: expense.title,
amount: Number(expense.amount)
},
[...APPROVAL_FLOW]
requiredApprovalTypes
);
}
@@ -144,6 +141,7 @@ export async function POST(request: Request) {
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
approvalStatus: expense.approvalStatus,
approvalThreshold,
requiredApprovalTypes,
rollback: {
kind: "expense.create",
created: snapshotExpense(expense)
+92 -4
View File
@@ -29,6 +29,8 @@ const CSV_HEADERS = [
"approvalPreference",
"approvalPermissions",
"approvalThreshold",
"requiredApprovalTypes",
"budgetReleaseNotifyTarget",
"title",
"description",
"amount",
@@ -41,6 +43,10 @@ const CSV_HEADERS = [
"recurrenceStartAt",
"invoiceDate",
"proofUrl",
"storedFileName",
"originalFileName",
"mimeType",
"fileSize",
"createdAt",
"paidAt",
"documentedAt",
@@ -58,7 +64,7 @@ const CSV_HEADERS = [
"auditMetadata"
] as const;
type CsvRow = Record<(typeof CSV_HEADERS)[number], string | number | null | undefined>;
type CsvRow = Partial<Record<(typeof CSV_HEADERS)[number], string | number | null | undefined>>;
export async function GET() {
const viewer = await getCurrentViewer();
@@ -149,6 +155,20 @@ export async function GET() {
}
}
}
},
documents: {
orderBy: {
createdAt: "asc"
},
include: {
uploadedBy: {
select: {
id: true,
name: true,
username: true
}
}
}
}
}
}
@@ -169,6 +189,8 @@ export async function GET() {
recordType: "settings",
id: appSettings.id,
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2),
requiredApprovalTypes: appSettings.requiredApprovalTypes.join("|"),
budgetReleaseNotifyTarget: appSettings.budgetReleaseNotifyTarget,
createdAt: appSettings.createdAt.toISOString()
} as CsvRow);
@@ -420,8 +442,12 @@ export async function GET() {
approvalType: "",
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
proofUrl: expense.proofUrl ?? "",
invoiceDate: "",
proofUrl: "",
storedFileName: "",
originalFileName: "",
mimeType: "",
fileSize: "",
createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? "",
documentedAt: expense.documentedAt?.toISOString() ?? "",
@@ -439,6 +465,64 @@ export async function GET() {
auditMetadata: ""
});
for (const document of expense.documents) {
rows.push({
recordType: "expenseDocument",
id: document.id,
parentId: expense.id,
parentType: "expense",
workingGroupId: group.id,
workingGroupName: group.name,
periodId: budget.period.id,
periodName: budget.period.name,
periodStartsAt: budget.period.startsAt.toISOString(),
periodEndsAt: budget.period.endsAt.toISOString(),
periodIsCurrent: budget.period.isCurrent ? "true" : "false",
budgetId: budget.id,
budgetName: budget.name,
userId: document.uploadedBy.id,
userName: document.uploadedBy.name,
username: document.uploadedBy.username,
passwordHash: "",
email: "",
role: "",
approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: expense.title,
description: "",
amount: Number(expense.amount).toFixed(2),
totalBudget: "",
releasedAmount: "",
colorCode: "",
approvalStatus: expense.approvalStatus,
approvalType: "",
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
invoiceDate: document.invoiceDate.toISOString(),
proofUrl: document.proofUrl,
storedFileName: document.storedFileName,
originalFileName: document.originalFileName,
mimeType: document.mimeType,
fileSize: document.size,
createdAt: document.createdAt.toISOString(),
paidAt: "",
documentedAt: "",
memberUsernames: "",
creatorName: expense.creator.name,
creatorUsername: expense.creator.username,
approverName: "",
approverUsername: "",
auditActorId: "",
auditAction: "",
auditEntityType: "",
auditEntityId: "",
auditEntityLabel: "",
auditSummary: "",
auditMetadata: ""
});
}
for (const approval of expense.approvals) {
rows.push({
recordType: "approval",
@@ -473,8 +557,12 @@ export async function GET() {
approvalType: approval.approvalType,
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
invoiceDate: "",
proofUrl: "",
storedFileName: "",
originalFileName: "",
mimeType: "",
fileSize: "",
createdAt: approval.timestamp.toISOString(),
paidAt: "",
documentedAt: "",
+44 -3
View File
@@ -2,7 +2,14 @@ import { NextResponse } from "next/server";
import { createAuditLog } from "@/lib/audit-log";
import { parseCsv } from "@/lib/backup-csv";
import { canManageUsers, DEFAULT_APPROVAL_THRESHOLD, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain";
import {
APPROVAL_FLOW,
canManageUsers,
DEFAULT_APPROVAL_THRESHOLD,
getLegacyApprovalPreference,
normalizeApprovalPermissions,
normalizeRequiredApprovalTypes
} from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
@@ -106,12 +113,14 @@ export async function POST(request: Request) {
const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup");
const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget");
const expenseRows = rawEntries.filter((entry) => entry.recordType === "expense");
const documentRows = rawEntries.filter((entry) => entry.recordType === "expenseDocument");
const approvalRows = rawEntries.filter((entry) => entry.recordType === "approval");
const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog");
try {
await prisma.$transaction(async (tx) => {
await tx.approval.deleteMany();
await tx.expenseDocument.deleteMany();
await tx.expense.deleteMany();
await tx.budget.deleteMany();
await tx.auditLog.deleteMany();
@@ -125,6 +134,16 @@ export async function POST(request: Request) {
data: {
id: settingsRow?.id || "global",
approvalThreshold: toNumber(settingsRow?.approvalThreshold) ?? DEFAULT_APPROVAL_THRESHOLD,
requiredApprovalTypes: normalizeRequiredApprovalTypes(
settingsRow?.requiredApprovalTypes
? (settingsRow.requiredApprovalTypes
.split("|")
.map((entry) => entry.trim())
.filter((entry) => APPROVAL_FLOW.includes(entry as (typeof APPROVAL_FLOW)[number])) as (typeof APPROVAL_FLOW)[number][])
: undefined
),
budgetReleaseNotifyTarget:
settingsRow?.budgetReleaseNotifyTarget === "GROUP_MEMBERS_ONLY" ? "GROUP_MEMBERS_ONLY" : "ALL_GROUP_USERS",
createdAt: toDate(settingsRow?.createdAt) ?? new Date()
}
});
@@ -226,8 +245,6 @@ export async function POST(request: Request) {
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
recurrenceStartAt: toDate(row.recurrenceStartAt),
invoiceDate: toDate(row.invoiceDate),
proofUrl: toNullable(row.proofUrl),
createdAt: toDate(row.createdAt) ?? new Date(),
paidAt: toDate(row.paidAt),
documentedAt: toDate(row.documentedAt)
@@ -235,6 +252,30 @@ export async function POST(request: Request) {
});
}
for (const row of documentRows) {
const invoiceDate = toDate(row.invoiceDate);
if (!invoiceDate) {
throw new Error(`Rechnungsdokument ${row.id} enthält kein gültiges Rechnungsdatum.`);
}
await tx.expenseDocument.create({
data: {
id: row.id,
expenseId: row.parentId,
invoiceDate,
proofUrl: row.proofUrl,
driveFileId: null,
originalFileName: row.originalFileName || row.storedFileName || "rechnung",
storedFileName: row.storedFileName || row.originalFileName || "rechnung",
mimeType: row.mimeType || "application/octet-stream",
size: toNumber(row.fileSize) ?? 0,
uploadedById: row.userId,
createdAt: toDate(row.createdAt) ?? new Date()
}
});
}
for (const row of approvalRows) {
const timestamp = toDate(row.createdAt);
+25 -8
View File
@@ -1,15 +1,17 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
import { getAppSettings, serializeAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
import { snapshotAppSettings } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log";
import { canManageUsers } from "@/lib/domain";
import { APPROVAL_FLOW, canManageSettings, canManageUsers, normalizeRequiredApprovalTypes } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
const settingsSchema = z.object({
approvalThreshold: z.coerce.number().min(0).max(100000)
approvalThreshold: z.coerce.number().min(0).max(100000).optional(),
requiredApprovalTypes: z.array(z.enum(APPROVAL_FLOW)).min(1).optional(),
budgetReleaseNotifyTarget: z.enum(["ALL_GROUP_USERS", "GROUP_MEMBERS_ONLY"]).optional()
});
export async function PATCH(request: Request) {
@@ -27,7 +29,14 @@ export async function PATCH(request: Request) {
const parsed = settingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Bitte eine gueltige Freigabe-Schwelle eingeben." }, { status: 400 });
return NextResponse.json({ error: "Bitte gueltige Einstellungen eingeben." }, { status: 400 });
}
const changesOrgaSettings =
parsed.data.requiredApprovalTypes !== undefined || parsed.data.budgetReleaseNotifyTarget !== undefined;
if (changesOrgaSettings && !canManageSettings(viewer.role)) {
return NextResponse.json({ error: "Nur AG Orga darf Zuständigkeiten und Benachrichtigungen ändern." }, { status: 403 });
}
const existingSettings = await getAppSettings();
@@ -38,7 +47,13 @@ export async function PATCH(request: Request) {
id: existingSettings.id
},
data: {
approvalThreshold: parsed.data.approvalThreshold
...(parsed.data.approvalThreshold !== undefined ? { approvalThreshold: parsed.data.approvalThreshold } : {}),
...(parsed.data.requiredApprovalTypes !== undefined
? { requiredApprovalTypes: normalizeRequiredApprovalTypes(parsed.data.requiredApprovalTypes) }
: {}),
...(parsed.data.budgetReleaseNotifyTarget !== undefined
? { budgetReleaseNotifyTarget: parsed.data.budgetReleaseNotifyTarget }
: {})
}
});
@@ -48,9 +63,11 @@ export async function PATCH(request: Request) {
entityType: "settings",
entityId: appSettings.id,
entityLabel: "Freigabe-Schwelle",
summary: `Freigabe-Schwelle wurde auf ${toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2)} EUR gesetzt.`,
summary: changesOrgaSettings
? "Zuständigkeiten und Benachrichtigungen wurden aktualisiert."
: `Freigabe-Schwelle wurde auf ${toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2)} EUR gesetzt.`,
metadata: {
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold),
settings: serializeAppSettings(appSettings),
rollback: {
kind: "settings.update",
previous: previousSnapshot
@@ -60,6 +77,6 @@ export async function PATCH(request: Request) {
return NextResponse.json({
ok: true,
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold)
settings: serializeAppSettings(appSettings)
});
}
+32 -4
View File
@@ -2,12 +2,13 @@ import { redirect } from "next/navigation";
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
import { getCurrentAccountingPeriod } from "@/lib/accounting-periods";
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
import { getAppSettings, serializeAppSettings } from "@/lib/app-settings";
import { getRollbackMetadata } from "@/lib/audit-log";
import type {
DashboardAccountingPeriod,
DashboardAuditLog,
DashboardManagedUser,
DashboardSettings,
DashboardViewer,
DashboardWorkingGroup
} from "@/lib/dashboard-types";
@@ -78,6 +79,19 @@ export default async function DashboardPage() {
}
}
}
},
documents: {
orderBy: {
createdAt: "asc"
},
include: {
uploadedBy: {
select: {
id: true,
username: true
}
}
}
}
}
}
@@ -185,8 +199,20 @@ export default async function DashboardPage() {
recurrenceStartAt,
paidAt: expense.paidAt?.toISOString() ?? null,
documentedAt: expense.documentedAt?.toISOString() ?? null,
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
proofUrl: expense.proofUrl,
documents: expense.documents.map((document) => ({
id: document.id,
invoiceDate: document.invoiceDate.toISOString(),
proofUrl: document.proofUrl,
storedFileName: document.storedFileName,
originalFileName: document.originalFileName,
mimeType: document.mimeType,
size: document.size,
createdAt: document.createdAt.toISOString(),
uploadedBy: {
id: document.uploadedBy.id,
name: document.uploadedBy.username
}
})),
createdAt: expense.createdAt.toISOString(),
creator: {
id: expense.creator.id,
@@ -226,6 +252,8 @@ export default async function DashboardPage() {
isCurrent: period.isCurrent
}));
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({
id: entry.id,
action: entry.action,
@@ -253,7 +281,7 @@ export default async function DashboardPage() {
auditLogs={serializedAuditLogs}
accountingPeriods={serializedPeriods}
currentPeriodId={currentPeriod.id}
approvalThreshold={toApprovalThresholdNumber(appSettings.approvalThreshold)}
settings={serializedSettings}
/>
);
}