AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
CI / Build and Deploy (push) Successful in 2m20s
CI / Build and Deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user