AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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";
|
||||||
@@ -31,6 +31,11 @@ enum ExpenseRecurrence {
|
|||||||
MONTHLY
|
MONTHLY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BudgetReleaseNotifyTarget {
|
||||||
|
ALL_GROUP_USERS
|
||||||
|
GROUP_MEMBERS_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -44,6 +49,7 @@ model User {
|
|||||||
workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull)
|
workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull)
|
||||||
createdExpenses Expense[] @relation("ExpenseCreator")
|
createdExpenses Expense[] @relation("ExpenseCreator")
|
||||||
approvals Approval[]
|
approvals Approval[]
|
||||||
|
uploadedDocuments ExpenseDocument[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -81,10 +87,12 @@ model AccountingPeriod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AppSettings {
|
model AppSettings {
|
||||||
id String @id @default("global")
|
id String @id @default("global")
|
||||||
approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold")
|
approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
requiredApprovalTypes ApprovalType[] @default([CHAIR_A, CHAIR_B, FINANCE]) @map("required_approval_types")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
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")
|
@@map("app_settings")
|
||||||
}
|
}
|
||||||
@@ -133,8 +141,6 @@ model Expense {
|
|||||||
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
recurrenceStartAt DateTime? @map("recurrence_start_at")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
documentedAt DateTime? @map("documented_at")
|
documentedAt DateTime? @map("documented_at")
|
||||||
invoiceDate DateTime? @map("invoice_date")
|
|
||||||
proofUrl String? @map("proof_url")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
creator User @relation("ExpenseCreator", fields: [creatorId], references: [id], onDelete: Restrict)
|
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)
|
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict)
|
||||||
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
|
||||||
approvals Approval[]
|
approvals Approval[]
|
||||||
|
documents ExpenseDocument[]
|
||||||
|
|
||||||
@@map("expenses")
|
@@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 {
|
model Approval {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
expenseId String @map("expense_id")
|
expenseId String @map("expense_id")
|
||||||
|
|||||||
@@ -361,14 +361,24 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
where: {
|
where: {
|
||||||
id: asString(previous.id, "Einstellungs-ID")
|
id: asString(previous.id, "Einstellungs-ID")
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle")
|
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"),
|
||||||
},
|
requiredApprovalTypes: asApprovalPermissions(previous.requiredApprovalTypes),
|
||||||
create: {
|
budgetReleaseNotifyTarget:
|
||||||
id: asString(previous.id, "Einstellungs-ID"),
|
asString(previous.budgetReleaseNotifyTarget ?? "ALL_GROUP_USERS", "Budget-Push-Ziel") as
|
||||||
approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"),
|
| "ALL_GROUP_USERS"
|
||||||
createdAt: asDate(previous.createdAt, "Einstellungen erstellt am") ?? new Date()
|
| "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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -500,10 +510,11 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "expense.delete": {
|
case "expense.delete": {
|
||||||
const deleted = asRecord(rollback.deleted, "Ausgabe");
|
const deleted = asRecord(rollback.deleted, "Ausgabe");
|
||||||
|
const deletedDocuments = Array.isArray(rollback.deletedDocuments) ? rollback.deletedDocuments : [];
|
||||||
|
|
||||||
await tx.expense.create({
|
await tx.expense.create({
|
||||||
data: {
|
data: {
|
||||||
id: asString(deleted.id, "Ausgabe-ID"),
|
id: asString(deleted.id, "Ausgabe-ID"),
|
||||||
title: asString(deleted.title, "Titel"),
|
title: asString(deleted.title, "Titel"),
|
||||||
@@ -516,15 +527,32 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
|
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
|
||||||
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
|
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
|
||||||
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
|
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
|
||||||
invoiceDate: asDate(deleted.invoiceDate, "Rechnungsdatum"),
|
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
|
||||||
proofUrl: asNullableString(deleted.proofUrl),
|
|
||||||
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
|
|
||||||
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
|
paidAt: asDate(deleted.paidAt, "Bezahlt am"),
|
||||||
documentedAt: asDate(deleted.documentedAt, "Dokumentiert am")
|
documentedAt: asDate(deleted.documentedAt, "Dokumentiert am")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
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": {
|
case "expense.approve": {
|
||||||
const approval = asRecord(rollback.approval, "Freigabe");
|
const approval = asRecord(rollback.approval, "Freigabe");
|
||||||
@@ -568,16 +596,23 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "expense.document": {
|
case "expense.document":
|
||||||
await tx.expense.update({
|
case "expense.document.create": {
|
||||||
where: {
|
if (rollback.documentId) {
|
||||||
id: asString(rollback.expenseId, "Ausgabe-ID")
|
await tx.expenseDocument.deleteMany({
|
||||||
},
|
where: {
|
||||||
data: {
|
id: asString(rollback.documentId, "Dokument-ID")
|
||||||
proofUrl: asNullableString(rollback.previousProofUrl),
|
}
|
||||||
invoiceDate: asDate(rollback.previousInvoiceDate, "Vorheriges Rechnungsdatum"),
|
});
|
||||||
documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt"),
|
}
|
||||||
paidAt: asDate(rollback.previousPaidAt, "Vorheriger Bezahlt-Zeitpunkt")
|
|
||||||
|
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;
|
break;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Prisma } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAppSettings } from "@/lib/app-settings";
|
||||||
import { snapshotBudget } from "@/lib/audit-snapshots";
|
import { snapshotBudget } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import { canManageBudgets } from "@/lib/domain";
|
import { canManageBudgets } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notifyBudgetRelease } from "@/lib/push-notifications";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
const updateBudgetSchema = z
|
const updateBudgetSchema = z
|
||||||
@@ -44,7 +46,15 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const budget = await prisma.budget.findUnique({
|
const budget = await prisma.budget.findUnique({
|
||||||
where: { id }
|
where: { id },
|
||||||
|
include: {
|
||||||
|
workingGroup: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!budget) {
|
if (!budget) {
|
||||||
@@ -61,6 +71,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
try {
|
try {
|
||||||
const previousBudget = budget;
|
const previousBudget = budget;
|
||||||
const nextReleasedAmount = parsed.data.releasedAmount ?? Number(previousBudget.releasedAmount);
|
const nextReleasedAmount = parsed.data.releasedAmount ?? Number(previousBudget.releasedAmount);
|
||||||
|
const previousReleasedAmount = Number(previousBudget.releasedAmount);
|
||||||
const updatedBudget = await prisma.budget.update({
|
const updatedBudget = await prisma.budget.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
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, {
|
await createAuditLog(prisma, {
|
||||||
actorId: viewer.id,
|
actorId: viewer.id,
|
||||||
action: "budget.update",
|
action: "budget.update",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
APPROVAL_FLOW,
|
APPROVAL_FLOW,
|
||||||
getAvailableApprovalTypes,
|
getAvailableApprovalTypes,
|
||||||
normalizeApprovalPermissions,
|
normalizeApprovalPermissions,
|
||||||
|
normalizeRequiredApprovalTypes,
|
||||||
requiresManualApproval
|
requiresManualApproval
|
||||||
} from "@/lib/domain";
|
} from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
@@ -46,6 +47,7 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
||||||
|
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
|
||||||
|
|
||||||
if (!requiresManualApproval(Number(expense.amount), approvalThreshold)) {
|
if (!requiresManualApproval(Number(expense.amount), approvalThreshold)) {
|
||||||
return NextResponse.json({ error: "Diese Ausgabe ist bereits automatisch freigegeben." }, { status: 400 });
|
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.approvalPermissions,
|
||||||
viewer.approvalPreference
|
viewer.approvalPreference
|
||||||
);
|
);
|
||||||
const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals);
|
const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals, requiredApprovalTypes);
|
||||||
|
|
||||||
if (!availableApprovals.includes(parsed.data.approvalType)) {
|
if (!availableApprovals.includes(parsed.data.approvalType)) {
|
||||||
return NextResponse.json({ error: "Du darfst diese Freigabe nicht setzen." }, { status: 403 });
|
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 approvalTypes = approvals.map((approval) => approval.approvalType);
|
||||||
const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType))
|
const approvalStatus = requiredApprovalTypes.every((approvalType) => approvalTypes.includes(approvalType))
|
||||||
? "APPROVED"
|
? "APPROVED"
|
||||||
: "PENDING";
|
: "PENDING";
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
approvalType: parsed.data.approvalType,
|
approvalType: parsed.data.approvalType,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
|
requiredApprovalTypes,
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.approve",
|
kind: "expense.approve",
|
||||||
approval: snapshotApproval(transactionResult.approval),
|
approval: snapshotApproval(transactionResult.approval),
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import { canDocumentExpense } from "@/lib/domain";
|
import { canDocumentExpense } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
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 = {
|
type Context = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: Context) {
|
export async function POST(_: Request, { params }: Context) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const viewer = await getCurrentViewer();
|
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 });
|
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({
|
const updatedExpense = await prisma.expense.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
proofUrl: parsed.data.proofUrl ?? expense.proofUrl,
|
|
||||||
documentedAt: expense.documentedAt ?? new Date()
|
documentedAt: expense.documentedAt ?? new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,13 +54,10 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
entityLabel: updatedExpense.title,
|
entityLabel: updatedExpense.title,
|
||||||
summary: `Ausgabe ${updatedExpense.title} wurde dokumentiert.`,
|
summary: `Ausgabe ${updatedExpense.title} wurde dokumentiert.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
proofUrl: parsed.data.proofUrl ?? updatedExpense.proofUrl,
|
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.document",
|
kind: "expense.document",
|
||||||
expenseId: updatedExpense.id,
|
expenseId: updatedExpense.id,
|
||||||
previousProofUrl: expense.proofUrl,
|
|
||||||
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
|
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||||
nextProofUrl: updatedExpense.proofUrl,
|
|
||||||
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null
|
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,14 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expense = await prisma.expense.findUnique({
|
const expense = await prisma.expense.findUnique({
|
||||||
where: { id }
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
documents: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!expense) {
|
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 });
|
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,
|
title: expense.title,
|
||||||
invoiceDate: invoiceDate.toISOString().slice(0, 10),
|
invoiceDate: invoiceDate.toISOString().slice(0, 10),
|
||||||
|
sequence: expense._count.documents + 1,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
buffer: Buffer.from(await file.arrayBuffer())
|
buffer: Buffer.from(await file.arrayBuffer())
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedExpense = await prisma.expense.update({
|
const now = new Date();
|
||||||
where: { id: expense.id },
|
const transactionResult = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const document = await tx.expenseDocument.create({
|
||||||
proofUrl,
|
data: {
|
||||||
invoiceDate,
|
expenseId: expense.id,
|
||||||
documentedAt: expense.documentedAt ?? new Date(),
|
invoiceDate,
|
||||||
paidAt: expense.paidAt ?? new Date()
|
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, {
|
await createAuditLog(prisma, {
|
||||||
actorId: viewer.id,
|
actorId: viewer.id,
|
||||||
action: "expense.document",
|
action: "expense.document",
|
||||||
entityType: "expense",
|
entityType: "expense",
|
||||||
entityId: updatedExpense.id,
|
entityId: transactionResult.updatedExpense.id,
|
||||||
entityLabel: updatedExpense.title,
|
entityLabel: transactionResult.updatedExpense.title,
|
||||||
summary: `Rechnung fuer ${updatedExpense.title} wurde abgegeben.`,
|
summary: `Rechnung fuer ${transactionResult.updatedExpense.title} wurde abgegeben.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
proofUrl: updatedExpense.proofUrl,
|
documentId: transactionResult.document.id,
|
||||||
invoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null,
|
proofUrl: transactionResult.document.proofUrl,
|
||||||
|
invoiceDate: transactionResult.document.invoiceDate.toISOString(),
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.document",
|
kind: "expense.document.create",
|
||||||
expenseId: updatedExpense.id,
|
expenseId: transactionResult.updatedExpense.id,
|
||||||
previousProofUrl: expense.proofUrl,
|
documentId: transactionResult.document.id,
|
||||||
previousInvoiceDate: expense.invoiceDate?.toISOString() ?? null,
|
|
||||||
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
|
previousDocumentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||||
nextProofUrl: updatedExpense.proofUrl,
|
nextDocumentedAt: transactionResult.updatedExpense.documentedAt?.toISOString() ?? null,
|
||||||
nextInvoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null,
|
|
||||||
nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null,
|
|
||||||
previousPaidAt: expense.paidAt?.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({
|
const expense = await prisma.expense.findUnique({
|
||||||
where: { id }
|
where: { id },
|
||||||
|
include: {
|
||||||
|
documents: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!expense) {
|
if (!expense) {
|
||||||
@@ -56,7 +59,20 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.delete",
|
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 { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
|
||||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
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 prisma from "@/lib/prisma";
|
||||||
import { notifyApprovalRequest } from "@/lib/push-notifications";
|
import { notifyApprovalRequest } from "@/lib/push-notifications";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
@@ -40,11 +40,7 @@ const expenseSchema = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parseDateInput(value) ?? "invalid";
|
return parseDateInput(value) ?? "invalid";
|
||||||
}),
|
})
|
||||||
proofUrl: z
|
|
||||||
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()])
|
|
||||||
.optional()
|
|
||||||
.transform(() => undefined)
|
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) {
|
if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) {
|
||||||
@@ -97,6 +93,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
|
||||||
|
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
|
||||||
const recurrenceStartAt =
|
const recurrenceStartAt =
|
||||||
parsed.data.recurrence === "MONTHLY" && parsed.data.recurrenceStartAt instanceof Date
|
parsed.data.recurrence === "MONTHLY" && parsed.data.recurrenceStartAt instanceof Date
|
||||||
? parsed.data.recurrenceStartAt
|
? parsed.data.recurrenceStartAt
|
||||||
@@ -125,7 +122,7 @@ export async function POST(request: Request) {
|
|||||||
title: expense.title,
|
title: expense.title,
|
||||||
amount: Number(expense.amount)
|
amount: Number(expense.amount)
|
||||||
},
|
},
|
||||||
[...APPROVAL_FLOW]
|
requiredApprovalTypes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +141,7 @@ export async function POST(request: Request) {
|
|||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
|
requiredApprovalTypes,
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "expense.create",
|
kind: "expense.create",
|
||||||
created: snapshotExpense(expense)
|
created: snapshotExpense(expense)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const CSV_HEADERS = [
|
|||||||
"approvalPreference",
|
"approvalPreference",
|
||||||
"approvalPermissions",
|
"approvalPermissions",
|
||||||
"approvalThreshold",
|
"approvalThreshold",
|
||||||
|
"requiredApprovalTypes",
|
||||||
|
"budgetReleaseNotifyTarget",
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
"amount",
|
"amount",
|
||||||
@@ -41,6 +43,10 @@ const CSV_HEADERS = [
|
|||||||
"recurrenceStartAt",
|
"recurrenceStartAt",
|
||||||
"invoiceDate",
|
"invoiceDate",
|
||||||
"proofUrl",
|
"proofUrl",
|
||||||
|
"storedFileName",
|
||||||
|
"originalFileName",
|
||||||
|
"mimeType",
|
||||||
|
"fileSize",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"paidAt",
|
"paidAt",
|
||||||
"documentedAt",
|
"documentedAt",
|
||||||
@@ -58,7 +64,7 @@ const CSV_HEADERS = [
|
|||||||
"auditMetadata"
|
"auditMetadata"
|
||||||
] as const;
|
] 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() {
|
export async function GET() {
|
||||||
const viewer = await getCurrentViewer();
|
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",
|
recordType: "settings",
|
||||||
id: appSettings.id,
|
id: appSettings.id,
|
||||||
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2),
|
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2),
|
||||||
|
requiredApprovalTypes: appSettings.requiredApprovalTypes.join("|"),
|
||||||
|
budgetReleaseNotifyTarget: appSettings.budgetReleaseNotifyTarget,
|
||||||
createdAt: appSettings.createdAt.toISOString()
|
createdAt: appSettings.createdAt.toISOString()
|
||||||
} as CsvRow);
|
} as CsvRow);
|
||||||
|
|
||||||
@@ -420,8 +442,12 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||||
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
|
invoiceDate: "",
|
||||||
proofUrl: expense.proofUrl ?? "",
|
proofUrl: "",
|
||||||
|
storedFileName: "",
|
||||||
|
originalFileName: "",
|
||||||
|
mimeType: "",
|
||||||
|
fileSize: "",
|
||||||
createdAt: expense.createdAt.toISOString(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? "",
|
paidAt: expense.paidAt?.toISOString() ?? "",
|
||||||
documentedAt: expense.documentedAt?.toISOString() ?? "",
|
documentedAt: expense.documentedAt?.toISOString() ?? "",
|
||||||
@@ -439,6 +465,64 @@ export async function GET() {
|
|||||||
auditMetadata: ""
|
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) {
|
for (const approval of expense.approvals) {
|
||||||
rows.push({
|
rows.push({
|
||||||
recordType: "approval",
|
recordType: "approval",
|
||||||
@@ -473,8 +557,12 @@ export async function GET() {
|
|||||||
approvalType: approval.approvalType,
|
approvalType: approval.approvalType,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
|
||||||
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
|
storedFileName: "",
|
||||||
|
originalFileName: "",
|
||||||
|
mimeType: "",
|
||||||
|
fileSize: "",
|
||||||
createdAt: approval.timestamp.toISOString(),
|
createdAt: approval.timestamp.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
documentedAt: "",
|
documentedAt: "",
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import { parseCsv } from "@/lib/backup-csv";
|
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 prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
@@ -106,12 +113,14 @@ export async function POST(request: Request) {
|
|||||||
const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup");
|
const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup");
|
||||||
const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget");
|
const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget");
|
||||||
const expenseRows = rawEntries.filter((entry) => entry.recordType === "expense");
|
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 approvalRows = rawEntries.filter((entry) => entry.recordType === "approval");
|
||||||
const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog");
|
const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.approval.deleteMany();
|
await tx.approval.deleteMany();
|
||||||
|
await tx.expenseDocument.deleteMany();
|
||||||
await tx.expense.deleteMany();
|
await tx.expense.deleteMany();
|
||||||
await tx.budget.deleteMany();
|
await tx.budget.deleteMany();
|
||||||
await tx.auditLog.deleteMany();
|
await tx.auditLog.deleteMany();
|
||||||
@@ -125,6 +134,16 @@ export async function POST(request: Request) {
|
|||||||
data: {
|
data: {
|
||||||
id: settingsRow?.id || "global",
|
id: settingsRow?.id || "global",
|
||||||
approvalThreshold: toNumber(settingsRow?.approvalThreshold) ?? DEFAULT_APPROVAL_THRESHOLD,
|
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()
|
createdAt: toDate(settingsRow?.createdAt) ?? new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -226,8 +245,6 @@ export async function POST(request: Request) {
|
|||||||
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
|
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
|
||||||
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
|
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
|
||||||
recurrenceStartAt: toDate(row.recurrenceStartAt),
|
recurrenceStartAt: toDate(row.recurrenceStartAt),
|
||||||
invoiceDate: toDate(row.invoiceDate),
|
|
||||||
proofUrl: toNullable(row.proofUrl),
|
|
||||||
createdAt: toDate(row.createdAt) ?? new Date(),
|
createdAt: toDate(row.createdAt) ?? new Date(),
|
||||||
paidAt: toDate(row.paidAt),
|
paidAt: toDate(row.paidAt),
|
||||||
documentedAt: toDate(row.documentedAt)
|
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) {
|
for (const row of approvalRows) {
|
||||||
const timestamp = toDate(row.createdAt);
|
const timestamp = toDate(row.createdAt);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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 { snapshotAppSettings } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
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 prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
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) {
|
export async function PATCH(request: Request) {
|
||||||
@@ -27,7 +29,14 @@ export async function PATCH(request: Request) {
|
|||||||
const parsed = settingsSchema.safeParse(body);
|
const parsed = settingsSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
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();
|
const existingSettings = await getAppSettings();
|
||||||
@@ -38,7 +47,13 @@ export async function PATCH(request: Request) {
|
|||||||
id: existingSettings.id
|
id: existingSettings.id
|
||||||
},
|
},
|
||||||
data: {
|
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",
|
entityType: "settings",
|
||||||
entityId: appSettings.id,
|
entityId: appSettings.id,
|
||||||
entityLabel: "Freigabe-Schwelle",
|
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: {
|
metadata: {
|
||||||
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold),
|
settings: serializeAppSettings(appSettings),
|
||||||
rollback: {
|
rollback: {
|
||||||
kind: "settings.update",
|
kind: "settings.update",
|
||||||
previous: previousSnapshot
|
previous: previousSnapshot
|
||||||
@@ -60,6 +77,6 @@ export async function PATCH(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold)
|
settings: serializeAppSettings(appSettings)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
||||||
import { getCurrentAccountingPeriod } from "@/lib/accounting-periods";
|
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 { getRollbackMetadata } from "@/lib/audit-log";
|
||||||
import type {
|
import type {
|
||||||
DashboardAccountingPeriod,
|
DashboardAccountingPeriod,
|
||||||
DashboardAuditLog,
|
DashboardAuditLog,
|
||||||
DashboardManagedUser,
|
DashboardManagedUser,
|
||||||
|
DashboardSettings,
|
||||||
DashboardViewer,
|
DashboardViewer,
|
||||||
DashboardWorkingGroup
|
DashboardWorkingGroup
|
||||||
} from "@/lib/dashboard-types";
|
} 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,
|
recurrenceStartAt,
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||||
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
documentedAt: expense.documentedAt?.toISOString() ?? null,
|
||||||
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
|
documents: expense.documents.map((document) => ({
|
||||||
proofUrl: expense.proofUrl,
|
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(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
creator: {
|
creator: {
|
||||||
id: expense.creator.id,
|
id: expense.creator.id,
|
||||||
@@ -226,6 +252,8 @@ export default async function DashboardPage() {
|
|||||||
isCurrent: period.isCurrent
|
isCurrent: period.isCurrent
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
|
||||||
|
|
||||||
const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({
|
const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
@@ -253,7 +281,7 @@ export default async function DashboardPage() {
|
|||||||
auditLogs={serializedAuditLogs}
|
auditLogs={serializedAuditLogs}
|
||||||
accountingPeriods={serializedPeriods}
|
accountingPeriods={serializedPeriods}
|
||||||
currentPeriodId={currentPeriod.id}
|
currentPeriodId={currentPeriod.id}
|
||||||
approvalThreshold={toApprovalThresholdNumber(appSettings.approvalThreshold)}
|
settings={serializedSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
||||||
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
|
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
|
||||||
import {
|
import {
|
||||||
APPROVAL_FLOW,
|
|
||||||
approvalLabel,
|
approvalLabel,
|
||||||
canDeleteExpense,
|
canDeleteExpense,
|
||||||
canDocumentExpense,
|
canDocumentExpense,
|
||||||
@@ -48,6 +47,7 @@ type BudgetColumnProps = {
|
|||||||
viewer: DashboardViewer;
|
viewer: DashboardViewer;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
approvalThreshold: number;
|
approvalThreshold: number;
|
||||||
|
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
|
||||||
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
||||||
onMarkPaid: (expenseId: string) => Promise<void>;
|
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||||
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||||
@@ -140,6 +140,7 @@ export function BudgetColumn({
|
|||||||
viewer,
|
viewer,
|
||||||
busy,
|
busy,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
|
requiredApprovalTypes,
|
||||||
onApprove,
|
onApprove,
|
||||||
onMarkPaid,
|
onMarkPaid,
|
||||||
onDocument,
|
onDocument,
|
||||||
@@ -156,8 +157,7 @@ export function BudgetColumn({
|
|||||||
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
|
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
|
||||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
||||||
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({});
|
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
||||||
const [invoiceDateDrafts, setInvoiceDateDrafts] = useState<Record<string, string>>({});
|
|
||||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const budgetCardWidth = 352;
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
@@ -231,7 +259,8 @@ export function BudgetColumn({
|
|||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98),
|
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98),
|
||||||
backgroundImage: "none"
|
backgroundImage: "none",
|
||||||
|
touchAction: "pan-y"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 3 }}>
|
<CardContent sx={{ p: 3 }}>
|
||||||
@@ -384,20 +413,16 @@ export function BudgetColumn({
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
overflowX: { xs: "auto", md: "visible" },
|
overflow: "visible",
|
||||||
overflowY: "hidden",
|
pb: 0,
|
||||||
pb: { xs: 1.5, md: 0 },
|
alignItems: "stretch",
|
||||||
alignItems: "stretch",
|
width: { md: desktopBudgetListWidth },
|
||||||
scrollSnapType: { xs: "x proximity", md: "none" },
|
minWidth: { md: desktopBudgetListWidth }
|
||||||
scrollbarGutter: { xs: "stable both-edges", md: "auto" },
|
}}
|
||||||
overscrollBehaviorX: "contain",
|
|
||||||
width: { md: desktopBudgetListWidth },
|
|
||||||
minWidth: { md: desktopBudgetListWidth }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{group.budgets.map((budget) => {
|
{group.budgets.map((budget) => {
|
||||||
const draft = getDraft(budget);
|
const draft = getDraft(budget);
|
||||||
@@ -644,10 +669,10 @@ export function BudgetColumn({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{budget.expenses.map((expense) => {
|
{budget.expenses.map((expense) => {
|
||||||
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
|
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
|
||||||
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
|
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
|
||||||
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
|
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes, requiredApprovalTypes)
|
||||||
: [];
|
: [];
|
||||||
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
||||||
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
||||||
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
||||||
@@ -655,15 +680,16 @@ export function BudgetColumn({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2.25,
|
p: 2.25,
|
||||||
borderRadius: "18px",
|
borderRadius: "18px",
|
||||||
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
expense.approvalStatus === "APPROVED"
|
expense.approvalStatus === "APPROVED"
|
||||||
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
|
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
|
||||||
: alpha(budget.colorCode, isDark ? 0.1 : 0.04)
|
: alpha(budget.colorCode, isDark ? 0.1 : 0.04),
|
||||||
}}
|
touchAction: "pan-y"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.4}>
|
<Stack spacing={1.4}>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
@@ -753,7 +779,7 @@ export function BudgetColumn({
|
|||||||
|
|
||||||
{requiresManualApproval(expense.amount, approvalThreshold) ? (
|
{requiresManualApproval(expense.amount, approvalThreshold) ? (
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
{APPROVAL_FLOW.map((approvalType) => {
|
{requiredApprovalTypes.map((approvalType) => {
|
||||||
const matchingApproval = expense.approvals.find(
|
const matchingApproval = expense.approvals.find(
|
||||||
(approval) => approval.approvalType === approvalType
|
(approval) => approval.approvalType === approvalType
|
||||||
);
|
);
|
||||||
@@ -776,25 +802,27 @@ export function BudgetColumn({
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{expense.proofUrl ? (
|
{expense.documents.length > 0 ? (
|
||||||
<Stack spacing={0.4}>
|
<Stack spacing={0.5}>
|
||||||
<Link
|
{expense.documents.map((document, documentIndex) => (
|
||||||
href={expense.proofUrl}
|
<Box key={document.id}>
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noreferrer"
|
href={document.proofUrl}
|
||||||
underline="hover"
|
target="_blank"
|
||||||
variant="body2"
|
rel="noreferrer"
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
underline="hover"
|
||||||
>
|
variant="body2"
|
||||||
{"Rechnungsdokument \u00f6ffnen"}
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
</Link>
|
>
|
||||||
{expense.invoiceDate ? (
|
{`Rechnung ${documentIndex + 1}: ${document.storedFileName}`}
|
||||||
<Typography variant="caption" color="text.secondary">
|
</Link>
|
||||||
Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))}
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
||||||
</Typography>
|
Rechnung vom {dateFormatter.format(new Date(document.invoiceDate))}
|
||||||
) : null}
|
</Typography>
|
||||||
</Stack>
|
</Box>
|
||||||
) : null}
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
{availableApprovals.map((approvalType) => (
|
{availableApprovals.map((approvalType) => (
|
||||||
@@ -869,28 +897,10 @@ export function BudgetColumn({
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{!expense.paidAt &&
|
{expense.approvalStatus === "APPROVED" && canUploadProof ? (
|
||||||
expense.approvalStatus === "APPROVED" &&
|
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
|
||||||
!expense.proofUrl &&
|
<Stack direction="row" spacing={1}>
|
||||||
canUploadProof ? (
|
<Button
|
||||||
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
|
|
||||||
<TextField
|
|
||||||
label="Rechnungsdatum"
|
|
||||||
type="date"
|
|
||||||
value={invoiceDateDrafts[expense.id] ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInvoiceDateDrafts((current) => ({
|
|
||||||
...current,
|
|
||||||
[expense.id]: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
size="small"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
component="label"
|
component="label"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -900,18 +910,14 @@ export function BudgetColumn({
|
|||||||
sx={{ minWidth: 0 }}
|
sx={{ minWidth: 0 }}
|
||||||
>
|
>
|
||||||
Datei
|
Datei
|
||||||
<input
|
<input
|
||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,application/pdf"
|
multiple
|
||||||
onChange={(event) =>
|
accept="image/*,application/pdf"
|
||||||
setProofFileDrafts((current) => ({
|
onChange={(event) => addProofFiles(expense.id, event.target.files)}
|
||||||
...current,
|
/>
|
||||||
[expense.id]: event.target.files?.[0] ?? null
|
</Button>
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
component="label"
|
component="label"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -924,28 +930,42 @@ export function BudgetColumn({
|
|||||||
Kamera
|
Kamera
|
||||||
<input
|
<input
|
||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
capture="environment"
|
||||||
onChange={(event) =>
|
onChange={(event) => addProofFiles(expense.id, event.target.files)}
|
||||||
setProofFileDrafts((current) => ({
|
/>
|
||||||
...current,
|
</Button>
|
||||||
[expense.id]: event.target.files?.[0] ?? null
|
</Stack>
|
||||||
}))
|
{(proofFileDrafts[expense.id] ?? []).map((entry, entryIndex) => (
|
||||||
}
|
<Stack key={`${entry.file.name}-${entryIndex}`} spacing={0.7}>
|
||||||
/>
|
<Typography variant="caption" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
|
||||||
</Button>
|
{entry.file.name}
|
||||||
</Stack>
|
</Typography>
|
||||||
{proofFileDrafts[expense.id]?.name ? (
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||||
<Typography
|
<TextField
|
||||||
variant="caption"
|
label="Rechnungsdatum"
|
||||||
color="text.secondary"
|
type="date"
|
||||||
sx={{ display: "block", overflowWrap: "anywhere" }}
|
value={entry.invoiceDate}
|
||||||
>
|
onChange={(event) => updateProofInvoiceDate(expense.id, entryIndex, event.target.value)}
|
||||||
{proofFileDrafts[expense.id]?.name}
|
InputLabelProps={{ shrink: true }}
|
||||||
</Typography>
|
size="small"
|
||||||
) : null}
|
required
|
||||||
<Button
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removeProofDraft(expense.id, entryIndex)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
@@ -960,18 +980,24 @@ export function BudgetColumn({
|
|||||||
textAlign: "center"
|
textAlign: "center"
|
||||||
}}
|
}}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const proofFile = proofFileDrafts[expense.id];
|
const proofDrafts = proofFileDrafts[expense.id] ?? [];
|
||||||
const invoiceDate = invoiceDateDrafts[expense.id] ?? "";
|
|
||||||
|
|
||||||
if (!proofFile || !invoiceDate) {
|
if (proofDrafts.length === 0 || proofDrafts.some((entry) => !entry.invoiceDate)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await onUploadProof(expense.id, proofFile, invoiceDate);
|
for (const entry of proofDrafts) {
|
||||||
}}
|
await onUploadProof(expense.id, entry.file, entry.invoiceDate);
|
||||||
>
|
}
|
||||||
Rechnung abgeben und bezahlt setzen
|
|
||||||
</Button>
|
setProofFileDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: []
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expense.paidAt ? "Rechnung nachreichen" : "Rechnung abgeben und bezahlt setzen"}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
|
|||||||
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
|
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
|
||||||
import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded";
|
import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded";
|
||||||
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
||||||
|
import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
|
||||||
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
|
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
|
||||||
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
|
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
|
||||||
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
|
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
|
||||||
@@ -17,8 +18,15 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
@@ -39,6 +47,7 @@ import type {
|
|||||||
DashboardAccountingPeriod,
|
DashboardAccountingPeriod,
|
||||||
DashboardAuditLog,
|
DashboardAuditLog,
|
||||||
DashboardManagedUser,
|
DashboardManagedUser,
|
||||||
|
DashboardSettings,
|
||||||
DashboardViewer,
|
DashboardViewer,
|
||||||
DashboardWorkingGroup
|
DashboardWorkingGroup
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
@@ -58,7 +67,7 @@ type DashboardShellProps = {
|
|||||||
auditLogs: DashboardAuditLog[];
|
auditLogs: DashboardAuditLog[];
|
||||||
accountingPeriods: DashboardAccountingPeriod[];
|
accountingPeriods: DashboardAccountingPeriod[];
|
||||||
currentPeriodId: string;
|
currentPeriodId: string;
|
||||||
approvalThreshold: number;
|
settings: DashboardSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExpenseFormState = {
|
type ExpenseFormState = {
|
||||||
@@ -115,6 +124,11 @@ type PeriodEditFormState = {
|
|||||||
endsAt: string;
|
endsAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrgaSettingsDraft = {
|
||||||
|
requiredApprovalTypes: ApprovalPermissionValue[];
|
||||||
|
budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardMessage = {
|
type DashboardMessage = {
|
||||||
type: "success" | "error";
|
type: "success" | "error";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -249,7 +263,7 @@ export function DashboardShell({
|
|||||||
auditLogs,
|
auditLogs,
|
||||||
accountingPeriods,
|
accountingPeriods,
|
||||||
currentPeriodId,
|
currentPeriodId,
|
||||||
approvalThreshold
|
settings
|
||||||
}: DashboardShellProps) {
|
}: DashboardShellProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
@@ -264,6 +278,7 @@ export function DashboardShell({
|
|||||||
const canManageAccounts = canManageUsers(viewer.role);
|
const canManageAccounts = canManageUsers(viewer.role);
|
||||||
const canManagePeriods = canManageBudgets(viewer.role);
|
const canManagePeriods = canManageBudgets(viewer.role);
|
||||||
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
|
||||||
|
const approvalThreshold = settings.approvalThreshold;
|
||||||
const desktopSections = [
|
const desktopSections = [
|
||||||
{ value: "overview" as const, label: "\u00dcbersicht" },
|
{ value: "overview" as const, label: "\u00dcbersicht" },
|
||||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
||||||
@@ -310,35 +325,25 @@ export function DashboardShell({
|
|||||||
});
|
});
|
||||||
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
|
||||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||||
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(
|
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||||
viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""
|
|
||||||
);
|
|
||||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
|
||||||
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
||||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||||
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||||
const [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers));
|
const [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers));
|
||||||
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
|
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
|
||||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||||
|
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
|
||||||
|
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
|
||||||
|
requiredApprovalTypes: settings.requiredApprovalTypes,
|
||||||
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
||||||
|
});
|
||||||
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
||||||
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
||||||
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
||||||
useEffect(() => {
|
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(() => {
|
|
||||||
setSelectedCurrentPeriodId(currentPeriodId);
|
setSelectedCurrentPeriodId(currentPeriodId);
|
||||||
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
||||||
}, [currentPeriod, currentPeriodId]);
|
}, [currentPeriod, currentPeriodId]);
|
||||||
@@ -473,6 +478,13 @@ export function DashboardShell({
|
|||||||
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
|
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
|
||||||
}, [approvalThreshold]);
|
}, [approvalThreshold]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrgaSettingsDraft({
|
||||||
|
requiredApprovalTypes: settings.requiredApprovalTypes,
|
||||||
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
||||||
|
});
|
||||||
|
}, [settings.budgetReleaseNotifyTarget, settings.requiredApprovalTypes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setManagedUsersState(sortManagedUsersList(managedUsers));
|
setManagedUsersState(sortManagedUsersList(managedUsers));
|
||||||
}, [managedUsers]);
|
}, [managedUsers]);
|
||||||
@@ -482,11 +494,10 @@ export function DashboardShell({
|
|||||||
setEditingUserId(null);
|
setEditingUserId(null);
|
||||||
}
|
}
|
||||||
}, [editingUserId, managedUsersState]);
|
}, [editingUserId, managedUsersState]);
|
||||||
const selectedExpenseGroup =
|
const selectedExpenseGroup =
|
||||||
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
||||||
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
||||||
const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0];
|
const selectedBudgetWorkingGroup =
|
||||||
const selectedBudgetWorkingGroup =
|
|
||||||
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
|
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
|
||||||
const selectedBudgetReleaseGroup =
|
const selectedBudgetReleaseGroup =
|
||||||
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||||
@@ -767,14 +778,14 @@ export function DashboardShell({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
)) as { proofUrl: string };
|
)) as { document: { proofUrl: string } };
|
||||||
|
|
||||||
setMessage({ type: "success", text: "Rechnung wurde abgegeben und die Ausgabe ist jetzt bezahlt." });
|
setMessage({ type: "success", text: "Rechnung wurde abgegeben und die Ausgabe ist jetzt bezahlt." });
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.proofUrl;
|
return result.document.proofUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden.";
|
const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden.";
|
||||||
setMessage({ type: "error", text });
|
setMessage({ type: "error", text });
|
||||||
@@ -1059,6 +1070,27 @@ export function DashboardShell({
|
|||||||
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
}, `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() {
|
async function handleEnablePushNotifications() {
|
||||||
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
|
||||||
setPushStatus("unsupported");
|
setPushStatus("unsupported");
|
||||||
@@ -2345,46 +2377,30 @@ export function DashboardShell({
|
|||||||
|
|
||||||
const overviewContent = (
|
const overviewContent = (
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
{isCompactLayout && visibleGroups.length > 1 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Stack spacing={1.5}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
|
|
||||||
AG auswählen
|
|
||||||
</Typography>
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
Mobil zeigen wir jeweils eine AG auf einmal, damit die Budgetkarten sauber lesbar bleiben.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="Sichtbare AG"
|
|
||||||
value={selectedMobileGroupId}
|
|
||||||
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{visibleGroups.map((group) => (
|
|
||||||
<MenuItem key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isCompactLayout ? (
|
{isCompactLayout ? (
|
||||||
<Stack direction="column" gap={2} sx={{ width: "100%", minWidth: 0 }}>
|
<Box
|
||||||
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
|
sx={{
|
||||||
<Box key={group.id} sx={{ width: "100%", minWidth: 0 }}>
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflowX: "auto",
|
||||||
|
overflowY: "hidden",
|
||||||
|
pb: 2,
|
||||||
|
scrollbarGutter: "stable both-edges",
|
||||||
|
overscrollBehaviorX: "contain",
|
||||||
|
touchAction: "pan-x pan-y"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" gap={2} sx={{ width: "max-content", minWidth: "100%", alignItems: "stretch" }}>
|
||||||
|
{visibleGroups.map((group) => (
|
||||||
|
<Box key={group.id} sx={{ width: "min(88vw, 456px)", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||||
<BudgetColumn
|
<BudgetColumn
|
||||||
group={group}
|
group={group}
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
onApprove={handleApprove}
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||||
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
onUploadProof={handleUploadProof}
|
onUploadProof={handleUploadProof}
|
||||||
@@ -2395,8 +2411,9 @@ export function DashboardShell({
|
|||||||
onDeleteExpense={handleDeleteExpense}
|
onDeleteExpense={handleDeleteExpense}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -2404,11 +2421,12 @@ export function DashboardShell({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
overflowY: "hidden",
|
overflowY: "hidden",
|
||||||
pb: 2,
|
pb: 2,
|
||||||
scrollbarGutter: "stable both-edges",
|
scrollbarGutter: "stable both-edges",
|
||||||
overscrollBehaviorX: "contain"
|
overscrollBehaviorX: "contain",
|
||||||
}}
|
touchAction: "pan-x pan-y"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@@ -2425,9 +2443,10 @@ export function DashboardShell({
|
|||||||
<BudgetColumn
|
<BudgetColumn
|
||||||
group={group}
|
group={group}
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
onApprove={handleApprove}
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||||
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
onUploadProof={handleUploadProof}
|
onUploadProof={handleUploadProof}
|
||||||
@@ -2502,8 +2521,8 @@ export function DashboardShell({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
|
||||||
{viewer.approvalPermissions.length > 0 ? (
|
{viewer.approvalPermissions.length > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant={pushStatus === "enabled" ? "contained" : "outlined"}
|
variant={pushStatus === "enabled" ? "contained" : "outlined"}
|
||||||
@@ -2513,9 +2532,18 @@ export function DashboardShell({
|
|||||||
onClick={handleEnablePushNotifications}
|
onClick={handleEnablePushNotifications}
|
||||||
>
|
>
|
||||||
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
|
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Chip
|
{viewer.role === "ORGA" ? (
|
||||||
|
<IconButton
|
||||||
|
aria-label="Zuständigkeiten und Benachrichtigungen"
|
||||||
|
sx={{ border: `1px solid ${alpha("#FFFFFF", 0.28)}`, color: "white" }}
|
||||||
|
onClick={() => setIsOrgaSettingsOpen(true)}
|
||||||
|
>
|
||||||
|
<SettingsRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
<Chip
|
||||||
label={`${viewer.username} - ${roleLabel(viewer.role)}`}
|
label={`${viewer.username} - ${roleLabel(viewer.role)}`}
|
||||||
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}
|
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}
|
||||||
/>
|
/>
|
||||||
@@ -2567,9 +2595,66 @@ export function DashboardShell({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Container maxWidth={false} sx={{ maxWidth: 1640 }}>
|
<Dialog open={isOrgaSettingsOpen} onClose={() => setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Zuständigkeiten & Benachrichtigungen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2.5} sx={{ pt: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Freigaberollen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen.
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||||||
|
{APPROVAL_FLOW.map((approvalType) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={approvalType}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={orgaSettingsDraft.requiredApprovalTypes.includes(approvalType)}
|
||||||
|
onChange={() =>
|
||||||
|
setOrgaSettingsDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={approvalLabel(approvalType)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Budgetfreigabe-Push"
|
||||||
|
value={orgaSettingsDraft.budgetReleaseNotifyTarget}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOrgaSettingsDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
budgetReleaseNotifyTarget: event.target.value as OrgaSettingsDraft["budgetReleaseNotifyTarget"]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
helperText="Wer informiert wird, wenn ein Budget an eine AG übergeben wird."
|
||||||
|
>
|
||||||
|
<MenuItem value="ALL_GROUP_USERS">Alle Nutzer der AG</MenuItem>
|
||||||
|
<MenuItem value="GROUP_MEMBERS_ONLY">Nur AG-Mitglieder</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setIsOrgaSettingsOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" disabled={busy} onClick={handleSaveOrgaSettings}>
|
||||||
|
Einstellungen speichern
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Container maxWidth={false} sx={{ maxWidth: 1640 }}>
|
||||||
<Stack spacing={3} px={2}>
|
<Stack spacing={3} px={2}>
|
||||||
{message ? <Alert severity={message.type}>{message.text}</Alert> : null}
|
{message ? <Alert severity={message.type}>{message.text}</Alert> : null}
|
||||||
{periodOverviewCard}
|
{periodOverviewCard}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
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";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
type SettingsClient = PrismaClient | Prisma.TransactionClient;
|
type SettingsClient = PrismaClient | Prisma.TransactionClient;
|
||||||
@@ -13,7 +13,9 @@ export async function getAppSettings(client: SettingsClient = prisma) {
|
|||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
id: "global",
|
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());
|
const parsed = Number(typeof value === "number" ? value : value.toString());
|
||||||
return Number.isFinite(parsed) ? parsed : DEFAULT_APPROVAL_THRESHOLD;
|
return Number.isFinite(parsed) ? parsed : DEFAULT_APPROVAL_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serializeAppSettings(settings: Awaited<ReturnType<typeof getAppSettings>>) {
|
||||||
|
return {
|
||||||
|
approvalThreshold: toApprovalThresholdNumber(settings.approvalThreshold),
|
||||||
|
requiredApprovalTypes: normalizeRequiredApprovalTypes(settings.requiredApprovalTypes),
|
||||||
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapshotAppSettings(settings: Pick<AppSettings, "id" | "approvalThreshold" | "createdAt">) {
|
export function snapshotAppSettings(
|
||||||
|
settings: Pick<AppSettings, "id" | "approvalThreshold" | "requiredApprovalTypes" | "budgetReleaseNotifyTarget" | "createdAt">
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: settings.id,
|
id: settings.id,
|
||||||
approvalThreshold: Number(settings.approvalThreshold),
|
approvalThreshold: Number(settings.approvalThreshold),
|
||||||
|
requiredApprovalTypes: settings.requiredApprovalTypes,
|
||||||
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget,
|
||||||
createdAt: settings.createdAt.toISOString()
|
createdAt: settings.createdAt.toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,8 +60,6 @@ export function snapshotExpense(
|
|||||||
| "approvalStatus"
|
| "approvalStatus"
|
||||||
| "recurrence"
|
| "recurrence"
|
||||||
| "recurrenceStartAt"
|
| "recurrenceStartAt"
|
||||||
| "invoiceDate"
|
|
||||||
| "proofUrl"
|
|
||||||
| "createdAt"
|
| "createdAt"
|
||||||
| "paidAt"
|
| "paidAt"
|
||||||
| "documentedAt"
|
| "documentedAt"
|
||||||
@@ -75,8 +77,6 @@ export function snapshotExpense(
|
|||||||
approvalStatus: expense.approvalStatus,
|
approvalStatus: expense.approvalStatus,
|
||||||
recurrence: expense.recurrence,
|
recurrence: expense.recurrence,
|
||||||
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
|
||||||
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
|
|
||||||
proofUrl: expense.proofUrl,
|
|
||||||
createdAt: expense.createdAt.toISOString(),
|
createdAt: expense.createdAt.toISOString(),
|
||||||
paidAt: expense.paidAt?.toISOString() ?? null,
|
paidAt: expense.paidAt?.toISOString() ?? null,
|
||||||
documentedAt: expense.documentedAt?.toISOString() ?? null
|
documentedAt: expense.documentedAt?.toISOString() ?? null
|
||||||
|
|||||||
@@ -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 = {
|
export type DashboardAccountingPeriod = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +40,21 @@ export type DashboardExpenseOccurrence = {
|
|||||||
amount: number;
|
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 = {
|
export type DashboardExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -49,8 +70,7 @@ export type DashboardExpense = {
|
|||||||
recurrenceStartAt: string | null;
|
recurrenceStartAt: string | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
documentedAt: string | null;
|
documentedAt: string | null;
|
||||||
invoiceDate: string | null;
|
documents: DashboardExpenseDocument[];
|
||||||
proofUrl: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
creator: {
|
creator: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,6 +79,12 @@ export type DashboardExpense = {
|
|||||||
approvals: DashboardApproval[];
|
approvals: DashboardApproval[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardSettings = {
|
||||||
|
approvalThreshold: number;
|
||||||
|
requiredApprovalTypes: ApprovalTypeValue[];
|
||||||
|
budgetReleaseNotifyTarget: BudgetReleaseNotifyTargetValue;
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardBudget = {
|
export type DashboardBudget = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
|||||||
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
||||||
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
||||||
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
||||||
|
export type BudgetReleaseNotifyTargetValue = "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
|
||||||
|
|
||||||
export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
|
export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
|
||||||
return amount >= approvalThreshold;
|
return amount >= approvalThreshold;
|
||||||
@@ -68,6 +69,10 @@ export function canManageUsers(role: AppRole) {
|
|||||||
return hasAdministrativeAccess(role);
|
return hasAdministrativeAccess(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canManageSettings(role: AppRole) {
|
||||||
|
return role === "ORGA";
|
||||||
|
}
|
||||||
|
|
||||||
export function canMarkPaid(role: AppRole) {
|
export function canMarkPaid(role: AppRole) {
|
||||||
return canDocumentExpense(role);
|
return canDocumentExpense(role);
|
||||||
}
|
}
|
||||||
@@ -126,9 +131,15 @@ export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeVal
|
|||||||
|
|
||||||
export function getAvailableApprovalTypes(
|
export function getAvailableApprovalTypes(
|
||||||
approvalPermissions: ApprovalTypeValue[],
|
approvalPermissions: ApprovalTypeValue[],
|
||||||
existingApprovals: ApprovalTypeValue[]
|
existingApprovals: ApprovalTypeValue[],
|
||||||
|
requiredApprovalTypes: ApprovalTypeValue[] = [...APPROVAL_FLOW]
|
||||||
): ApprovalTypeValue[] {
|
): ApprovalTypeValue[] {
|
||||||
return APPROVAL_FLOW.filter(
|
return requiredApprovalTypes.filter(
|
||||||
(approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType)
|
(approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType)
|
||||||
) as ApprovalTypeValue[];
|
) 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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function sanitizeDriveFileName(title: string, fallback = "beleg") {
|
|||||||
export async function uploadExpenseProofToDrive(input: {
|
export async function uploadExpenseProofToDrive(input: {
|
||||||
title: string;
|
title: string;
|
||||||
invoiceDate: string;
|
invoiceDate: string;
|
||||||
|
sequence: number;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
@@ -42,7 +43,7 @@ export async function uploadExpenseProofToDrive(input: {
|
|||||||
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
|
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
|
||||||
const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : "";
|
const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : "";
|
||||||
const baseName = sanitizeDriveFileName(input.title);
|
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({
|
const response = await drive.files.create({
|
||||||
requestBody: {
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import webpush from "web-push";
|
import webpush from "web-push";
|
||||||
import type { ApprovalType } from "@prisma/client";
|
import type { ApprovalType, BudgetReleaseNotifyTarget } from "@prisma/client";
|
||||||
|
|
||||||
import { approvalLabel } from "@/lib/domain";
|
import { approvalLabel } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
@@ -10,6 +10,14 @@ type PushTargetExpense = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PushTargetBudgetRelease = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workingGroupId: string;
|
||||||
|
workingGroupName: string;
|
||||||
|
releasedAmount: number;
|
||||||
|
};
|
||||||
|
|
||||||
function configureWebPush() {
|
function configureWebPush() {
|
||||||
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
const privateKey = process.env.VAPID_PRIVATE_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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user