From f87a82e02f289e82dc822d0adad4ec4295a17370 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 5 May 2026 21:57:20 +0200 Subject: [PATCH] AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen --- .../migration.sql | 35 +++ prisma/schema.prisma | 39 ++- src/app/api/audit-logs/[id]/restore/route.ts | 93 +++++-- src/app/api/budgets/[id]/route.ts | 27 +- src/app/api/expenses/[id]/approve/route.ts | 7 +- src/app/api/expenses/[id]/documented/route.ts | 20 +- src/app/api/expenses/[id]/proof/route.ts | 95 +++++-- src/app/api/expenses/[id]/route.ts | 20 +- src/app/api/expenses/route.ts | 12 +- src/app/api/export/csv/route.ts | 96 ++++++- src/app/api/import/csv/route.ts | 47 +++- src/app/api/settings/route.ts | 33 ++- src/app/page.tsx | 36 ++- src/components/dashboard/budget-column.tsx | 260 ++++++++++-------- src/components/dashboard/dashboard-shell.tsx | 249 +++++++++++------ src/lib/app-settings.ts | 14 +- src/lib/audit-snapshots.ts | 10 +- src/lib/dashboard-types.ts | 32 ++- src/lib/domain.ts | 15 +- src/lib/google-drive.ts | 9 +- src/lib/push-notifications.ts | 59 +++- 21 files changed, 885 insertions(+), 323 deletions(-) create mode 100644 prisma/migrations/202605051200_settings_documents/migration.sql diff --git a/prisma/migrations/202605051200_settings_documents/migration.sql b/prisma/migrations/202605051200_settings_documents/migration.sql new file mode 100644 index 0000000..1674704 --- /dev/null +++ b/prisma/migrations/202605051200_settings_documents/migration.sql @@ -0,0 +1,35 @@ +CREATE TYPE "BudgetReleaseNotifyTarget" AS ENUM ('ALL_GROUP_USERS', 'GROUP_MEMBERS_ONLY'); + +ALTER TABLE "app_settings" + ADD COLUMN "required_approval_types" "ApprovalType"[] NOT NULL DEFAULT ARRAY['CHAIR_A', 'CHAIR_B', 'FINANCE']::"ApprovalType"[], + ADD COLUMN "budget_release_notify_target" "BudgetReleaseNotifyTarget" NOT NULL DEFAULT 'ALL_GROUP_USERS'; + +CREATE TABLE "expense_documents" ( + "id" TEXT NOT NULL, + "expense_id" TEXT NOT NULL, + "invoice_date" TIMESTAMP(3) NOT NULL, + "proof_url" TEXT NOT NULL, + "drive_file_id" TEXT, + "original_file_name" TEXT NOT NULL, + "stored_file_name" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "uploaded_by_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "expense_documents_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "expense_documents_expense_id_idx" ON "expense_documents"("expense_id"); +CREATE INDEX "expense_documents_uploaded_by_id_idx" ON "expense_documents"("uploaded_by_id"); + +ALTER TABLE "expense_documents" + ADD CONSTRAINT "expense_documents_expense_id_fkey" + FOREIGN KEY ("expense_id") REFERENCES "expenses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "expense_documents" + ADD CONSTRAINT "expense_documents_uploaded_by_id_fkey" + FOREIGN KEY ("uploaded_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "expenses" + DROP COLUMN "invoice_date", + DROP COLUMN "proof_url"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72d7433..f49d5ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,11 @@ enum ExpenseRecurrence { MONTHLY } +enum BudgetReleaseNotifyTarget { + ALL_GROUP_USERS + GROUP_MEMBERS_ONLY +} + model User { id String @id @default(cuid()) name String @@ -44,6 +49,7 @@ model User { workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull) createdExpenses Expense[] @relation("ExpenseCreator") approvals Approval[] + uploadedDocuments ExpenseDocument[] auditLogs AuditLog[] pushSubscriptions PushSubscription[] createdAt DateTime @default(now()) @map("created_at") @@ -81,10 +87,12 @@ model AccountingPeriod { } model AppSettings { - id String @id @default("global") - approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default("global") + approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold") + requiredApprovalTypes ApprovalType[] @default([CHAIR_A, CHAIR_B, FINANCE]) @map("required_approval_types") + budgetReleaseNotifyTarget BudgetReleaseNotifyTarget @default(ALL_GROUP_USERS) @map("budget_release_notify_target") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("app_settings") } @@ -133,8 +141,6 @@ model Expense { recurrenceStartAt DateTime? @map("recurrence_start_at") paidAt DateTime? @map("paid_at") documentedAt DateTime? @map("documented_at") - invoiceDate DateTime? @map("invoice_date") - proofUrl String? @map("proof_url") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") creator User @relation("ExpenseCreator", fields: [creatorId], references: [id], onDelete: Restrict) @@ -142,10 +148,31 @@ model Expense { budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) approvals Approval[] + documents ExpenseDocument[] @@map("expenses") } +model ExpenseDocument { + id String @id @default(cuid()) + expenseId String @map("expense_id") + invoiceDate DateTime @map("invoice_date") + proofUrl String @map("proof_url") + driveFileId String? @map("drive_file_id") + originalFileName String @map("original_file_name") + storedFileName String @map("stored_file_name") + mimeType String @map("mime_type") + size Int + uploadedById String @map("uploaded_by_id") + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + + @@index([expenseId]) + @@index([uploadedById]) + @@map("expense_documents") +} + model Approval { id String @id @default(cuid()) expenseId String @map("expense_id") diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index 659d35e..60530e7 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -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; diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts index 4dafa77..3abe2b4 100644 --- a/src/app/api/budgets/[id]/route.ts +++ b/src/app/api/budgets/[id]/route.ts @@ -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", diff --git a/src/app/api/expenses/[id]/approve/route.ts b/src/app/api/expenses/[id]/approve/route.ts index 599547a..9a4d836 100644 --- a/src/app/api/expenses/[id]/approve/route.ts +++ b/src/app/api/expenses/[id]/approve/route.ts @@ -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), diff --git a/src/app/api/expenses/[id]/documented/route.ts b/src/app/api/expenses/[id]/documented/route.ts index faedd22..6ac45bb 100644 --- a/src/app/api/expenses/[id]/documented/route.ts +++ b/src/app/api/expenses/[id]/documented/route.ts @@ -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 } } diff --git a/src/app/api/expenses/[id]/proof/route.ts b/src/app/api/expenses/[id]/proof/route.ts index 50b13c0..19d3d92 100644 --- a/src/app/api/expenses/[id]/proof/route.ts +++ b/src/app/api/expenses/[id]/proof/route.ts @@ -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 + }); } diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index 5290c57..75492c7 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -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() + })) } } }); diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index ead85f6..ee44602 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -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) diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index 005a34c..a0c335a 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -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>; 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: "", diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 58f829c..255ceb2 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -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); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 68312af..47a64d9 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -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) }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4a089d0..f9d4e8b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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} /> ); } diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 2605127..4cc877b 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -32,7 +32,6 @@ import { useEffect, useMemo, useState } from "react"; import { ColorPickerField } from "@/components/dashboard/color-picker-field"; import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; import { - APPROVAL_FLOW, approvalLabel, canDeleteExpense, canDocumentExpense, @@ -48,6 +47,7 @@ type BudgetColumnProps = { viewer: DashboardViewer; busy: boolean; approvalThreshold: number; + requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[]; onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; @@ -140,6 +140,7 @@ export function BudgetColumn({ viewer, busy, approvalThreshold, + requiredApprovalTypes, onApprove, onMarkPaid, onDocument, @@ -156,8 +157,7 @@ export function BudgetColumn({ const [editingBudgetId, setEditingBudgetId] = useState(null); const [isEditingGroup, setIsEditingGroup] = useState(false); const [groupDraftName, setGroupDraftName] = useState(group.name); - const [proofFileDrafts, setProofFileDrafts] = useState>({}); - const [invoiceDateDrafts, setInvoiceDateDrafts] = useState>({}); + const [proofFileDrafts, setProofFileDrafts] = useState>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); const budgetCardWidth = 352; @@ -223,6 +223,34 @@ export function BudgetColumn({ })); } + function addProofFiles(expenseId: string, files: FileList | null) { + if (!files || files.length === 0) { + return; + } + + const nextFiles = Array.from(files).map((file) => ({ file, invoiceDate: "" })); + setProofFileDrafts((current) => ({ + ...current, + [expenseId]: [...(current[expenseId] ?? []), ...nextFiles] + })); + } + + function updateProofInvoiceDate(expenseId: string, index: number, invoiceDate: string) { + setProofFileDrafts((current) => ({ + ...current, + [expenseId]: (current[expenseId] ?? []).map((entry, entryIndex) => + entryIndex === index ? { ...entry, invoiceDate } : entry + ) + })); + } + + function removeProofDraft(expenseId: string, index: number) { + setProofFileDrafts((current) => ({ + ...current, + [expenseId]: (current[expenseId] ?? []).filter((_, entryIndex) => entryIndex !== index) + })); + } + return ( @@ -384,20 +413,16 @@ export function BudgetColumn({ ) : null} - {group.budgets.map((budget) => { const draft = getDraft(budget); @@ -644,10 +669,10 @@ export function BudgetColumn({ ) : null} {budget.expenses.map((expense) => { - const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType); - const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold) - ? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes) - : []; + const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType); + const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold) + ? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes, requiredApprovalTypes) + : []; const isRecurringSeries = expense.recurrence === "MONTHLY"; const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false; const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role); @@ -655,15 +680,16 @@ export function BudgetColumn({ return ( @@ -753,7 +779,7 @@ export function BudgetColumn({ {requiresManualApproval(expense.amount, approvalThreshold) ? ( - {APPROVAL_FLOW.map((approvalType) => { + {requiredApprovalTypes.map((approvalType) => { const matchingApproval = expense.approvals.find( (approval) => approval.approvalType === approvalType ); @@ -776,25 +802,27 @@ export function BudgetColumn({ ) : null} - {expense.proofUrl ? ( - - - {"Rechnungsdokument \u00f6ffnen"} - - {expense.invoiceDate ? ( - - Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))} - - ) : null} - - ) : null} + {expense.documents.length > 0 ? ( + + {expense.documents.map((document, documentIndex) => ( + + + {`Rechnung ${documentIndex + 1}: ${document.storedFileName}`} + + + Rechnung vom {dateFormatter.format(new Date(document.invoiceDate))} + + + ))} + + ) : null} {availableApprovals.map((approvalType) => ( @@ -869,28 +897,10 @@ export function BudgetColumn({ ) : null} - {!expense.paidAt && - expense.approvalStatus === "APPROVED" && - !expense.proofUrl && - canUploadProof ? ( - - - setInvoiceDateDrafts((current) => ({ - ...current, - [expense.id]: event.target.value - })) - } - InputLabelProps={{ shrink: true }} - size="small" - required - fullWidth - /> - - + addProofFiles(expense.id, event.target.files)} + /> + - - {proofFileDrafts[expense.id]?.name ? ( - - {proofFileDrafts[expense.id]?.name} - - ) : null} - + + {(proofFileDrafts[expense.id] ?? []).map((entry, entryIndex) => ( + + + {entry.file.name} + + + updateProofInvoiceDate(expense.id, entryIndex, event.target.value)} + InputLabelProps={{ shrink: true }} + size="small" + required + fullWidth + /> + + + + ))} + + for (const entry of proofDrafts) { + await onUploadProof(expense.id, entry.file, entry.invoiceDate); + } + + setProofFileDrafts((current) => ({ + ...current, + [expense.id]: [] + })); + }} + > + {expense.paidAt ? "Rechnung nachreichen" : "Rechnung abgeben und bezahlt setzen"} + ) : null} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 88e6083..870ef23 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -8,6 +8,7 @@ import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; +import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded"; import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded"; import WalletRoundedIcon from "@mui/icons-material/WalletRounded"; @@ -17,8 +18,15 @@ import { Button, Card, CardContent, + Checkbox, Chip, Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, MenuItem, Stack, Tab, @@ -39,6 +47,7 @@ import type { DashboardAccountingPeriod, DashboardAuditLog, DashboardManagedUser, + DashboardSettings, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; @@ -58,7 +67,7 @@ type DashboardShellProps = { auditLogs: DashboardAuditLog[]; accountingPeriods: DashboardAccountingPeriod[]; currentPeriodId: string; - approvalThreshold: number; + settings: DashboardSettings; }; type ExpenseFormState = { @@ -115,6 +124,11 @@ type PeriodEditFormState = { endsAt: string; }; +type OrgaSettingsDraft = { + requiredApprovalTypes: ApprovalPermissionValue[]; + budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY"; +}; + type DashboardMessage = { type: "success" | "error"; text: string; @@ -249,7 +263,7 @@ export function DashboardShell({ auditLogs, accountingPeriods, currentPeriodId, - approvalThreshold + settings }: DashboardShellProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -264,6 +278,7 @@ export function DashboardShell({ const canManageAccounts = canManageUsers(viewer.role); const canManagePeriods = canManageBudgets(viewer.role); const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0]; + const approvalThreshold = settings.approvalThreshold; const desktopSections = [ { value: "overview" as const, label: "\u00dcbersicht" }, ...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []), @@ -310,35 +325,25 @@ export function DashboardShell({ }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); - const [mobileSection, setMobileSection] = useState("overview"); - const [desktopSection, setDesktopSection] = useState("overview"); - const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); - const [selectedMobileGroupId, setSelectedMobileGroupId] = useState( - viewer.workingGroupId ?? visibleGroups[0]?.id ?? "" - ); - const [backupFile, setBackupFile] = useState(null); + const [mobileSection, setMobileSection] = useState("overview"); + const [desktopSection, setDesktopSection] = useState("overview"); + const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); + const [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null); const [passwordDrafts, setPasswordDrafts] = useState>({}); const [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers)); const [userDrafts, setUserDrafts] = useState>({}); const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); + const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false); + const [orgaSettingsDraft, setOrgaSettingsDraft] = useState({ + requiredApprovalTypes: settings.requiredApprovalTypes, + budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget + }); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); - useEffect(() => { - if (visibleGroups.length === 0) { - setSelectedMobileGroupId(""); - return; - } - - const hasSelectedGroup = visibleGroups.some((group) => group.id === selectedMobileGroupId); - if (!hasSelectedGroup) { - setSelectedMobileGroupId(viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""); - } - }, [selectedMobileGroupId, viewer.workingGroupId, visibleGroups]); - - useEffect(() => { + useEffect(() => { setSelectedCurrentPeriodId(currentPeriodId); setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); }, [currentPeriod, currentPeriodId]); @@ -473,6 +478,13 @@ export function DashboardShell({ setApprovalThresholdDraft(approvalThreshold.toFixed(2)); }, [approvalThreshold]); + useEffect(() => { + setOrgaSettingsDraft({ + requiredApprovalTypes: settings.requiredApprovalTypes, + budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget + }); + }, [settings.budgetReleaseNotifyTarget, settings.requiredApprovalTypes]); + useEffect(() => { setManagedUsersState(sortManagedUsersList(managedUsers)); }, [managedUsers]); @@ -482,11 +494,10 @@ export function DashboardShell({ setEditingUserId(null); } }, [editingUserId, managedUsersState]); - const selectedExpenseGroup = - editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; - const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; - const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0]; - const selectedBudgetWorkingGroup = + const selectedExpenseGroup = + editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; + const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; + const selectedBudgetWorkingGroup = visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null; const selectedBudgetReleaseGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null; @@ -767,14 +778,14 @@ export function DashboardShell({ method: "POST", body: formData }) - )) as { proofUrl: string }; + )) as { document: { proofUrl: string } }; setMessage({ type: "success", text: "Rechnung wurde abgegeben und die Ausgabe ist jetzt bezahlt." }); startTransition(() => { router.refresh(); }); - return result.proofUrl; + return result.document.proofUrl; } catch (error) { const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden."; setMessage({ type: "error", text }); @@ -1059,6 +1070,27 @@ export function DashboardShell({ }, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`); } + async function handleSaveOrgaSettings() { + if (orgaSettingsDraft.requiredApprovalTypes.length === 0) { + setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle ausw\u00e4hlen." }); + return; + } + + await runAction(async () => { + await parseResponse( + await fetch("/api/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(orgaSettingsDraft) + }) + ); + + setIsOrgaSettingsOpen(false); + }, "Zust\u00e4ndigkeiten und Benachrichtigungen wurden gespeichert."); + } + async function handleEnablePushNotifications() { if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { setPushStatus("unsupported"); @@ -2345,46 +2377,30 @@ export function DashboardShell({ const overviewContent = ( - {isCompactLayout && visibleGroups.length > 1 ? ( - - - - - - AG auswählen - - - Mobil zeigen wir jeweils eine AG auf einmal, damit die Budgetkarten sauber lesbar bleiben. - - - setSelectedMobileGroupId(event.target.value)} - fullWidth - > - {visibleGroups.map((group) => ( - - {group.name} - - ))} - - - - - ) : null} - {isCompactLayout ? ( - - {(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => ( - + + + {visibleGroups.map((group) => ( + - ))} - + ))} + + ) : ( - {viewer.approvalPermissions.length > 0 ? ( - - ) : null} - + ) : null} + {viewer.role === "ORGA" ? ( + setIsOrgaSettingsOpen(true)} + > + + + ) : null} + @@ -2567,9 +2595,66 @@ export function DashboardShell({ - + - + setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm"> + Zuständigkeiten & Benachrichtigungen + + + + + Freigaberollen + + + Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen. + + + {APPROVAL_FLOW.map((approvalType) => ( + + setOrgaSettingsDraft((current) => ({ + ...current, + requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType) + })) + } + /> + } + label={approvalLabel(approvalType)} + /> + ))} + + + + setOrgaSettingsDraft((current) => ({ + ...current, + budgetReleaseNotifyTarget: event.target.value as OrgaSettingsDraft["budgetReleaseNotifyTarget"] + })) + } + fullWidth + helperText="Wer informiert wird, wenn ein Budget an eine AG übergeben wird." + > + Alle Nutzer der AG + Nur AG-Mitglieder + + + + + + + + + + {message ? {message.text} : null} {periodOverviewCard} diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 61ff4e9..3e22a15 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -1,6 +1,6 @@ import type { Prisma, PrismaClient } from "@prisma/client"; -import { DEFAULT_APPROVAL_THRESHOLD } from "@/lib/domain"; +import { APPROVAL_FLOW, DEFAULT_APPROVAL_THRESHOLD, normalizeRequiredApprovalTypes } from "@/lib/domain"; import prisma from "@/lib/prisma"; type SettingsClient = PrismaClient | Prisma.TransactionClient; @@ -13,7 +13,9 @@ export async function getAppSettings(client: SettingsClient = prisma) { update: {}, create: { id: "global", - approvalThreshold: DEFAULT_APPROVAL_THRESHOLD + approvalThreshold: DEFAULT_APPROVAL_THRESHOLD, + requiredApprovalTypes: [...APPROVAL_FLOW], + budgetReleaseNotifyTarget: "ALL_GROUP_USERS" } }); } @@ -22,3 +24,11 @@ export function toApprovalThresholdNumber(value: { toString(): string } | number const parsed = Number(typeof value === "number" ? value : value.toString()); return Number.isFinite(parsed) ? parsed : DEFAULT_APPROVAL_THRESHOLD; } + +export function serializeAppSettings(settings: Awaited>) { + return { + approvalThreshold: toApprovalThresholdNumber(settings.approvalThreshold), + requiredApprovalTypes: normalizeRequiredApprovalTypes(settings.requiredApprovalTypes), + budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget + }; +} diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts index 9eafc6c..e5ff85a 100644 --- a/src/lib/audit-snapshots.ts +++ b/src/lib/audit-snapshots.ts @@ -19,10 +19,14 @@ export function snapshotPeriod(period: Pick) { +export function snapshotAppSettings( + settings: Pick +) { return { id: settings.id, approvalThreshold: Number(settings.approvalThreshold), + requiredApprovalTypes: settings.requiredApprovalTypes, + budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget, createdAt: settings.createdAt.toISOString() }; } @@ -56,8 +60,6 @@ export function snapshotExpense( | "approvalStatus" | "recurrence" | "recurrenceStartAt" - | "invoiceDate" - | "proofUrl" | "createdAt" | "paidAt" | "documentedAt" @@ -75,8 +77,6 @@ export function snapshotExpense( approvalStatus: expense.approvalStatus, recurrence: expense.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null, - invoiceDate: expense.invoiceDate?.toISOString() ?? null, - proofUrl: expense.proofUrl, createdAt: expense.createdAt.toISOString(), paidAt: expense.paidAt?.toISOString() ?? null, documentedAt: expense.documentedAt?.toISOString() ?? null diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index 33f5b69..ac17074 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -1,4 +1,10 @@ -import type { AppRole, ApprovalStatusValue, ApprovalTypeValue, ExpenseRecurrenceValue } from "@/lib/domain"; +import type { + AppRole, + ApprovalStatusValue, + ApprovalTypeValue, + BudgetReleaseNotifyTargetValue, + ExpenseRecurrenceValue +} from "@/lib/domain"; export type DashboardAccountingPeriod = { id: string; @@ -34,6 +40,21 @@ export type DashboardExpenseOccurrence = { amount: number; }; +export type DashboardExpenseDocument = { + id: string; + invoiceDate: string; + proofUrl: string; + storedFileName: string; + originalFileName: string; + mimeType: string; + size: number; + createdAt: string; + uploadedBy: { + id: string; + name: string; + }; +}; + export type DashboardExpense = { id: string; title: string; @@ -49,8 +70,7 @@ export type DashboardExpense = { recurrenceStartAt: string | null; paidAt: string | null; documentedAt: string | null; - invoiceDate: string | null; - proofUrl: string | null; + documents: DashboardExpenseDocument[]; createdAt: string; creator: { id: string; @@ -59,6 +79,12 @@ export type DashboardExpense = { approvals: DashboardApproval[]; }; +export type DashboardSettings = { + approvalThreshold: number; + requiredApprovalTypes: ApprovalTypeValue[]; + budgetReleaseNotifyTarget: BudgetReleaseNotifyTargetValue; +}; + export type DashboardBudget = { id: string; name: string; diff --git a/src/lib/domain.ts b/src/lib/domain.ts index 2396836..a13d4a0 100644 --- a/src/lib/domain.ts +++ b/src/lib/domain.ts @@ -18,6 +18,7 @@ export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number]; export type ApprovalStatusValue = "PENDING" | "APPROVED"; export type ExpenseRecurrenceValue = "NONE" | "MONTHLY"; +export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY"; export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) { return amount >= approvalThreshold; @@ -68,6 +69,10 @@ export function canManageUsers(role: AppRole) { return hasAdministrativeAccess(role); } +export function canManageSettings(role: AppRole) { + return role === "ORGA"; +} + export function canMarkPaid(role: AppRole) { return canDocumentExpense(role); } @@ -126,9 +131,15 @@ export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeVal export function getAvailableApprovalTypes( approvalPermissions: ApprovalTypeValue[], - existingApprovals: ApprovalTypeValue[] + existingApprovals: ApprovalTypeValue[], + requiredApprovalTypes: ApprovalTypeValue[] = [...APPROVAL_FLOW] ): ApprovalTypeValue[] { - return APPROVAL_FLOW.filter( + return requiredApprovalTypes.filter( (approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType) ) as ApprovalTypeValue[]; } + +export function normalizeRequiredApprovalTypes(value: ApprovalTypeValue[] | null | undefined) { + const normalized = APPROVAL_FLOW.filter((approvalType) => value?.includes(approvalType)); + return normalized.length > 0 ? normalized : [...APPROVAL_FLOW]; +} diff --git a/src/lib/google-drive.ts b/src/lib/google-drive.ts index a5610af..a1cf89f 100644 --- a/src/lib/google-drive.ts +++ b/src/lib/google-drive.ts @@ -34,6 +34,7 @@ export function sanitizeDriveFileName(title: string, fallback = "beleg") { export async function uploadExpenseProofToDrive(input: { title: string; invoiceDate: string; + sequence: number; fileName: string; mimeType: string; buffer: Buffer; @@ -42,7 +43,7 @@ export async function uploadExpenseProofToDrive(input: { const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID; const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : ""; const baseName = sanitizeDriveFileName(input.title); - const name = `${input.invoiceDate}-${baseName}${extension}`; + const name = `${input.invoiceDate}-${baseName}-${String(input.sequence).padStart(2, "0")}${extension}`; const response = await drive.files.create({ requestBody: { @@ -68,5 +69,9 @@ export async function uploadExpenseProofToDrive(input: { } }); - return response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`; + return { + driveFileId: response.data.id, + proofUrl: response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`, + storedFileName: name + }; } diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 2eb48d9..5cfaed3 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1,5 +1,5 @@ import webpush from "web-push"; -import type { ApprovalType } from "@prisma/client"; +import type { ApprovalType, BudgetReleaseNotifyTarget } from "@prisma/client"; import { approvalLabel } from "@/lib/domain"; import prisma from "@/lib/prisma"; @@ -10,6 +10,14 @@ type PushTargetExpense = { amount: number; }; +type PushTargetBudgetRelease = { + id: string; + name: string; + workingGroupId: string; + workingGroupName: string; + releasedAmount: number; +}; + function configureWebPush() { const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; const privateKey = process.env.VAPID_PRIVATE_KEY; @@ -92,3 +100,52 @@ export async function notifyApprovalRequest(expense: PushTargetExpense, approval }) ); } + +export async function notifyBudgetRelease(budget: PushTargetBudgetRelease, target: BudgetReleaseNotifyTarget) { + if (!configureWebPush()) { + return; + } + + const subscriptions = await prisma.pushSubscription.findMany({ + where: { + user: { + workingGroupId: budget.workingGroupId, + ...(target === "GROUP_MEMBERS_ONLY" ? { role: "MEMBER" } : {}) + } + } + }); + + await Promise.all( + subscriptions.map(async (subscription) => { + const payload = JSON.stringify({ + title: "Budget freigegeben", + body: `${budget.workingGroupName}: ${budget.name} wurde mit ${budget.releasedAmount.toFixed(2)} EUR freigegeben.`, + url: `/?budget=${encodeURIComponent(budget.id)}`, + tag: `budget-release-${budget.id}` + }); + + try { + await webpush.sendNotification( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth + } + }, + payload + ); + } catch (error) { + const statusCode = typeof error === "object" && error && "statusCode" in error ? error.statusCode : null; + + if (statusCode === 404 || statusCode === 410) { + await prisma.pushSubscription.delete({ + where: { + endpoint: subscription.endpoint + } + }); + } + } + }) + ); +}