diff --git a/prisma/migrations/202604121400_user_permissions_settings/migration.sql b/prisma/migrations/202604121400_user_permissions_settings/migration.sql new file mode 100644 index 0000000..37d7bfe --- /dev/null +++ b/prisma/migrations/202604121400_user_permissions_settings/migration.sql @@ -0,0 +1,22 @@ +ALTER TABLE "users" +ADD COLUMN "approval_permissions" "ApprovalType"[] NOT NULL DEFAULT ARRAY[]::"ApprovalType"[]; + +UPDATE "users" +SET "approval_permissions" = CASE + WHEN "approval_preference" IS NOT NULL THEN ARRAY["approval_preference"]::"ApprovalType"[] + WHEN "role" = 'FINANCE' THEN ARRAY['FINANCE']::"ApprovalType"[] + ELSE ARRAY[]::"ApprovalType"[] +END; + +CREATE TABLE "app_settings" ( + "id" TEXT NOT NULL, + "approval_threshold" DECIMAL(10,2) NOT NULL DEFAULT 50.00, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_settings_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "app_settings" ("id", "approval_threshold", "updated_at") +VALUES ('global', 50.00, CURRENT_TIMESTAMP) +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 472e7c6..2174341 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,20 +30,21 @@ enum ExpenseRecurrence { } model User { - id String @id @default(cuid()) - name String - username String @unique - email String? @unique - passwordHash String @map("password_hash") - role Role - approvalPreference ApprovalType? @map("approval_preference") - workingGroupId String? @map("working_group_id") - workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull) - createdExpenses Expense[] @relation("ExpenseCreator") - approvals Approval[] - auditLogs AuditLog[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(cuid()) + name String + username String @unique + email String? @unique + passwordHash String @map("password_hash") + role Role + approvalPreference ApprovalType? @map("approval_preference") + approvalPermissions ApprovalType[] @default([]) @map("approval_permissions") + workingGroupId String? @map("working_group_id") + workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull) + createdExpenses Expense[] @relation("ExpenseCreator") + approvals Approval[] + auditLogs AuditLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("users") } @@ -62,6 +63,15 @@ model AccountingPeriod { @@map("accounting_periods") } +model AppSettings { + id String @id @default("global") + approvalThreshold Decimal @default(50) @db.Decimal(10, 2) @map("approval_threshold") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("app_settings") +} + model WorkingGroup { id String @id @default(cuid()) name String @unique @@ -75,42 +85,42 @@ model WorkingGroup { } model Budget { - id String @id @default(cuid()) + id String @id @default(cuid()) name String - totalBudget Decimal @db.Decimal(10, 2) @map("total_budget") - colorCode String @map("color_code") - workingGroupId String @map("working_group_id") - periodId String @map("period_id") - workingGroup WorkingGroup @relation(fields: [workingGroupId], references: [id], onDelete: Cascade) + totalBudget Decimal @db.Decimal(10, 2) @map("total_budget") + colorCode String @map("color_code") + workingGroupId String @map("working_group_id") + periodId String @map("period_id") + workingGroup WorkingGroup @relation(fields: [workingGroupId], references: [id], onDelete: Cascade) period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) expenses Expense[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@unique([workingGroupId, periodId, name]) @@map("budgets") } model Expense { - id String @id @default(cuid()) + id String @id @default(cuid()) title String description String? - amount Decimal @db.Decimal(10, 2) - creatorId String @map("creator_id") - agId String @map("ag_id") - budgetId String @map("budget_id") - periodId String @map("period_id") - approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") + amount Decimal @db.Decimal(10, 2) + creatorId String @map("creator_id") + agId String @map("ag_id") + budgetId String @map("budget_id") + periodId String @map("period_id") + approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") recurrence ExpenseRecurrence @default(NONE) - paidAt DateTime? @map("paid_at") - documentedAt DateTime? @map("documented_at") - proofUrl String? @map("proof_url") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - creator User @relation("ExpenseCreator", fields: [creatorId], references: [id], onDelete: Restrict) - workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade) - budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) - period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) + paidAt DateTime? @map("paid_at") + documentedAt DateTime? @map("documented_at") + proofUrl String? @map("proof_url") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + creator User @relation("ExpenseCreator", fields: [creatorId], references: [id], onDelete: Restrict) + workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade) + budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) + period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) approvals Approval[] @@map("expenses") diff --git a/prisma/seed.ts b/prisma/seed.ts index b651b62..840d94c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,6 +3,21 @@ import bcrypt from "bcryptjs"; const prisma = new PrismaClient(); +const APPROVAL_THRESHOLD = 50; + +async function upsertAppSettings() { + await prisma.appSettings.upsert({ + where: { id: "global" }, + update: { + approvalThreshold: APPROVAL_THRESHOLD + }, + create: { + id: "global", + approvalThreshold: APPROVAL_THRESHOLD + } + }); +} + async function upsertCurrentPeriod() { const year = new Date().getFullYear(); const startsAt = new Date(Date.UTC(year, 0, 1)); @@ -36,9 +51,7 @@ async function upsertWorkingGroup(name: string) { return prisma.workingGroup.upsert({ where: { name }, update: {}, - create: { - name - } + create: { name } }); } @@ -71,8 +84,44 @@ async function upsertBudget( }); } +async function upsertUser(input: { + username: string; + role: Role; + passwordHash: string; + workingGroupId?: string | null; + approvalPermissions: ApprovalType[]; +}) { + const approvalPreference = input.approvalPermissions[0] ?? null; + + await prisma.user.upsert({ + where: { username: input.username }, + update: { + name: input.username, + username: input.username, + email: null, + passwordHash: input.passwordHash, + role: input.role, + approvalPreference, + approvalPermissions: input.approvalPermissions, + workingGroupId: input.workingGroupId ?? null + }, + create: { + name: input.username, + username: input.username, + email: null, + passwordHash: input.passwordHash, + role: input.role, + approvalPreference, + approvalPermissions: input.approvalPermissions, + workingGroupId: input.workingGroupId ?? null + } + }); +} + async function main() { const passwordHash = await bcrypt.hash("demo123!", 12); + + await upsertAppSettings(); const currentPeriod = await upsertCurrentPeriod(); const deko = await upsertWorkingGroup("AG Deko"); @@ -83,109 +132,41 @@ async function main() { await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D"); const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6"); - await prisma.user.upsert({ - where: { username: "vorstand-a" }, - update: { - name: "Admin 1", - username: "vorstand-a", - email: null, - passwordHash, - role: Role.ADMIN, - approvalPreference: ApprovalType.CHAIR_A, - workingGroupId: null - }, - create: { - name: "Admin 1", - username: "vorstand-a", - email: null, - passwordHash, - role: Role.ADMIN, - approvalPreference: ApprovalType.CHAIR_A - } + await upsertUser({ + username: "vorstand-a", + role: Role.ADMIN, + passwordHash, + approvalPermissions: [ApprovalType.CHAIR_A] }); - await prisma.user.upsert({ - where: { username: "vorstand-b" }, - update: { - name: "Admin 2", - username: "vorstand-b", - email: null, - passwordHash, - role: Role.ADMIN, - approvalPreference: ApprovalType.CHAIR_B, - workingGroupId: null - }, - create: { - name: "Admin 2", - username: "vorstand-b", - email: null, - passwordHash, - role: Role.ADMIN, - approvalPreference: ApprovalType.CHAIR_B - } + await upsertUser({ + username: "vorstand-b", + role: Role.ADMIN, + passwordHash, + approvalPermissions: [ApprovalType.CHAIR_B] }); - await prisma.user.upsert({ - where: { username: "finanzen" }, - update: { - name: "Finanz-AG", - username: "finanzen", - email: null, - passwordHash, - role: Role.FINANCE, - approvalPreference: ApprovalType.FINANCE, - workingGroupId: null - }, - create: { - name: "Finanz-AG", - username: "finanzen", - email: null, - passwordHash, - role: Role.FINANCE, - approvalPreference: ApprovalType.FINANCE - } + await upsertUser({ + username: "finanzen", + role: Role.FINANCE, + passwordHash, + approvalPermissions: [ApprovalType.FINANCE] }); - await prisma.user.upsert({ - where: { username: "deko" }, - update: { - name: "Deko Mitglied", - username: "deko", - email: null, - passwordHash, - role: Role.MEMBER, - approvalPreference: null, - workingGroupId: deko.id - }, - create: { - name: "Deko Mitglied", - username: "deko", - email: null, - passwordHash, - role: Role.MEMBER, - workingGroupId: deko.id - } + await upsertUser({ + username: "deko", + role: Role.MEMBER, + passwordHash, + workingGroupId: deko.id, + approvalPermissions: [] }); - await prisma.user.upsert({ - where: { username: "technik" }, - update: { - name: "Technik Mitglied", - username: "technik", - email: null, - passwordHash, - role: Role.MEMBER, - approvalPreference: null, - workingGroupId: technik.id - }, - create: { - name: "Technik Mitglied", - username: "technik", - email: null, - passwordHash, - role: Role.MEMBER, - workingGroupId: technik.id - } + await upsertUser({ + username: "technik", + role: Role.MEMBER, + passwordHash, + workingGroupId: technik.id, + approvalPermissions: [] }); const existingExpense = await prisma.expense.findFirst({ @@ -247,4 +228,4 @@ main() console.error(error); await prisma.$disconnect(); process.exit(1); - }); + }); \ No newline at end of file diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index 7fc6cd5..4ea62b7 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -53,6 +53,17 @@ function asNumber(value: unknown, label: string) { return value; } +function asApprovalPermissions(value: unknown) { + if (!Array.isArray(value)) { + return [] as ("CHAIR_A" | "CHAIR_B" | "FINANCE")[]; + } + + return value.filter( + (entry): entry is "CHAIR_A" | "CHAIR_B" | "FINANCE" => + entry === "CHAIR_A" || entry === "CHAIR_B" || entry === "FINANCE" + ); +} + export async function POST(_: Request, { params }: Context) { const viewer = await getCurrentViewer(); @@ -306,6 +317,25 @@ export async function POST(_: Request, { params }: Context) { break; } + case "settings.update": { + const previous = asRecord(rollback.previous, "App-Einstellungen"); + + await tx.appSettings.upsert({ + where: { + id: asString(previous.id, "Einstellungs-ID") + }, + update: { + approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle") + }, + create: { + id: asString(previous.id, "Einstellungs-ID"), + approvalThreshold: asNumber(previous.approvalThreshold, "Freigabe-Schwelle"), + createdAt: asDate(previous.createdAt, "Einstellungen erstellt am") ?? new Date() + } + }); + break; + } + case "user.create": { const created = asRecord(rollback.created, "Nutzer"); const userId = asString(created.id, "Nutzer-ID"); @@ -364,6 +394,7 @@ export async function POST(_: Request, { params }: Context) { passwordHash: asString(deleted.passwordHash, "Passworthash"), role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER", approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, + approvalPermissions: asApprovalPermissions(deleted.approvalPermissions), workingGroupId: asNullableString(deleted.workingGroupId), createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date() } @@ -371,6 +402,27 @@ export async function POST(_: Request, { params }: Context) { break; } + case "user.update": { + const previous = asRecord(rollback.previous, "Nutzer"); + const role = asString(previous.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER"; + + await tx.user.update({ + where: { + id: asString(previous.id, "Nutzer-ID") + }, + data: { + name: asString(previous.name, "Anzeigename"), + username: asString(previous.username, "Login-Name"), + email: asNullableString(previous.email), + role, + approvalPreference: asNullableString(previous.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, + approvalPermissions: asApprovalPermissions(previous.approvalPermissions), + workingGroupId: asNullableString(previous.workingGroupId) + } + }); + break; + } + case "user.passwordReset": { await tx.user.update({ where: { diff --git a/src/app/api/expenses/[id]/approve/route.ts b/src/app/api/expenses/[id]/approve/route.ts index 13e33fb..64cb66a 100644 --- a/src/app/api/expenses/[id]/approve/route.ts +++ b/src/app/api/expenses/[id]/approve/route.ts @@ -1,9 +1,15 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; import { snapshotApproval } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; -import { APPROVAL_FLOW, getAvailableApprovalTypes, requiresManualApproval } from "@/lib/domain"; +import { + APPROVAL_FLOW, + getAvailableApprovalTypes, + normalizeApprovalPermissions, + requiresManualApproval +} from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; @@ -24,18 +30,23 @@ export async function POST(request: Request, { params }: Context) { return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); } - const expense = await prisma.expense.findUnique({ - where: { id: params.id }, - include: { - approvals: true - } - }); + const [expense, appSettings] = await Promise.all([ + prisma.expense.findUnique({ + where: { id: params.id }, + include: { + approvals: true + } + }), + getAppSettings() + ]); if (!expense) { return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); } - if (!requiresManualApproval(Number(expense.amount))) { + const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold); + + if (!requiresManualApproval(Number(expense.amount), approvalThreshold)) { return NextResponse.json({ error: "Diese Ausgabe ist bereits automatisch freigegeben." }, { status: 400 }); } @@ -47,11 +58,12 @@ export async function POST(request: Request, { params }: Context) { } const existingApprovals = expense.approvals.map((approval) => approval.approvalType); - const availableApprovals = getAvailableApprovalTypes( + const viewerApprovalPermissions = normalizeApprovalPermissions( viewer.role, - viewer.approvalPreference, - existingApprovals + viewer.approvalPermissions, + viewer.approvalPreference ); + const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals); if (!availableApprovals.includes(parsed.data.approvalType)) { return NextResponse.json({ error: "Du darfst diese Freigabe nicht setzen." }, { status: 403 }); @@ -111,6 +123,7 @@ export async function POST(request: Request, { params }: Context) { summary: `${parsed.data.approvalType} fuer ${expense.title} wurde gesetzt.`, metadata: { approvalType: parsed.data.approvalType, + approvalThreshold, rollback: { kind: "expense.approve", approval: snapshotApproval(transactionResult.approval), diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index f9d77fe..039858b 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; import { snapshotExpense } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain"; @@ -18,7 +19,7 @@ const expenseSchema = z.object({ recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"), proofUrl: z .union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) - .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)), + .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)) }); export async function POST(request: Request) { @@ -39,14 +40,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Du kannst nur in deiner eigenen AG Ausgaben erfassen." }, { status: 403 }); } - const budget = await prisma.budget.findUnique({ - where: { id: parsed.data.budgetId } - }); + const [budget, appSettings] = await Promise.all([ + prisma.budget.findUnique({ + where: { id: parsed.data.budgetId } + }), + getAppSettings() + ]); if (!budget || budget.workingGroupId !== parsed.data.agId) { return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 }); } + const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold); + const expense = await prisma.expense.create({ data: { title: parsed.data.title, @@ -58,7 +64,7 @@ export async function POST(request: Request) { creatorId: viewer.id, proofUrl: parsed.data.proofUrl, recurrence: parsed.data.recurrence, - approvalStatus: requiresManualApproval(parsed.data.amount) ? "PENDING" : "APPROVED" + approvalStatus: requiresManualApproval(parsed.data.amount, approvalThreshold) ? "PENDING" : "APPROVED" } }); @@ -75,6 +81,7 @@ export async function POST(request: Request) { workingGroupId: parsed.data.agId, recurrence: parsed.data.recurrence, approvalStatus: expense.approvalStatus, + approvalThreshold, rollback: { kind: "expense.create", created: snapshotExpense(expense) diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index 0a9febd..4e79b72 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; import { toCsvCell } from "@/lib/backup-csv"; import { canManageUsers } from "@/lib/domain"; import prisma from "@/lib/prisma"; @@ -26,6 +27,8 @@ const CSV_HEADERS = [ "email", "role", "approvalPreference", + "approvalPermissions", + "approvalThreshold", "title", "description", "amount", @@ -65,7 +68,8 @@ export async function GET() { return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen CSV-Backups herunterladen." }, { status: 403 }); } - const [users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([ + const [appSettings, users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([ + getAppSettings(), prisma.user.findMany({ include: { workingGroup: { @@ -96,7 +100,8 @@ export async function GET() { username: true, email: true, role: true, - approvalPreference: true + approvalPreference: true, + approvalPermissions: true }, orderBy: { username: "asc" @@ -157,6 +162,13 @@ export async function GET() { const rows: CsvRow[] = []; + rows.push({ + recordType: "settings", + id: appSettings.id, + approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2), + createdAt: appSettings.createdAt.toISOString() + } as CsvRow); + for (const user of users) { rows.push({ recordType: "user", @@ -179,6 +191,8 @@ export async function GET() { email: user.email, role: user.role, approvalPreference: user.approvalPreference ?? "", + approvalPermissions: user.approvalPermissions.join("|"), + approvalThreshold: "", title: "", description: "", amount: "", @@ -228,6 +242,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: "", description: "", amount: "", @@ -277,6 +293,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: "", description: "", amount: "", @@ -325,6 +343,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: "", description: "", amount: "", @@ -373,6 +393,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: expense.title, description: expense.description ?? "", amount: Number(expense.amount).toFixed(2), @@ -421,6 +443,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: expense.title, description: "", amount: Number(expense.amount).toFixed(2), @@ -473,6 +497,8 @@ export async function GET() { email: "", role: "", approvalPreference: "", + approvalPermissions: "", + approvalThreshold: "", title: "", description: "", amount: "", diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 634a996..209a60d 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { createAuditLog } from "@/lib/audit-log"; import { parseCsv } from "@/lib/backup-csv"; -import { canManageUsers } from "@/lib/domain"; +import { canManageUsers, DEFAULT_APPROVAL_THRESHOLD, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; @@ -28,6 +28,21 @@ function toNumber(value: string | undefined) { return Number.isFinite(parsed) ? parsed : null; } +function toApprovalPermissions( + value: string | undefined, + role: "ADMIN" | "FINANCE" | "MEMBER", + approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null +) { + const explicitPermissions = value + ? value + .split("|") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) as ("CHAIR_A" | "CHAIR_B" | "FINANCE")[] + : []; + + return normalizeApprovalPermissions(role, explicitPermissions, approvalPreference); +} + export async function POST(request: Request) { const viewer = await getCurrentViewer(); @@ -71,6 +86,7 @@ export async function POST(request: Request) { ); } + const settingsRows = rawEntries.filter((entry) => entry.recordType === "settings"); const periodRows = rawEntries.filter((entry) => entry.recordType === "period"); const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup"); const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget"); @@ -87,6 +103,16 @@ export async function POST(request: Request) { await tx.user.deleteMany(); await tx.workingGroup.deleteMany(); await tx.accountingPeriod.deleteMany(); + await tx.appSettings.deleteMany(); + + const settingsRow = settingsRows[0]; + await tx.appSettings.create({ + data: { + id: settingsRow?.id || "global", + approvalThreshold: toNumber(settingsRow?.approvalThreshold) ?? DEFAULT_APPROVAL_THRESHOLD, + createdAt: toDate(settingsRow?.createdAt) ?? new Date() + } + }); for (const row of periodRows) { const startsAt = toDate(row.periodStartsAt); @@ -119,6 +145,10 @@ export async function POST(request: Request) { } for (const row of userRows) { + const role = row.role as "ADMIN" | "FINANCE" | "MEMBER"; + const approvalPreference = toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null; + const approvalPermissions = toApprovalPermissions(row.approvalPermissions, role, approvalPreference); + await tx.user.create({ data: { id: row.id, @@ -126,8 +156,9 @@ export async function POST(request: Request) { username: row.username, email: toNullable(row.email), passwordHash: row.passwordHash, - role: row.role as "ADMIN" | "FINANCE" | "MEMBER", - approvalPreference: toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, + role, + approvalPreference: getLegacyApprovalPreference(approvalPermissions), + approvalPermissions, workingGroupId: toNullable(row.workingGroupId), createdAt: toDate(row.createdAt) ?? new Date() } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..ccc17c0 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; +import { snapshotAppSettings } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const settingsSchema = z.object({ + approvalThreshold: z.coerce.number().min(0).max(100000) +}); + +export async function PATCH(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageUsers(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Einstellungen aendern." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = settingsSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte eine gueltige Freigabe-Schwelle eingeben." }, { status: 400 }); + } + + const existingSettings = await getAppSettings(); + const previousSnapshot = snapshotAppSettings(existingSettings); + + const appSettings = await prisma.appSettings.update({ + where: { + id: existingSettings.id + }, + data: { + approvalThreshold: parsed.data.approvalThreshold + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "settings.update", + entityType: "settings", + entityId: appSettings.id, + entityLabel: "Freigabe-Schwelle", + summary: `Freigabe-Schwelle wurde auf ${toApprovalThresholdNumber(appSettings.approvalThreshold).toFixed(2)} EUR gesetzt.`, + metadata: { + approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold), + rollback: { + kind: "settings.update", + previous: previousSnapshot + } + } + }); + + return NextResponse.json({ + ok: true, + approvalThreshold: toApprovalThresholdNumber(appSettings.approvalThreshold) + }); +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 2b35d95..aa1c86b 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,17 +1,121 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { snapshotUser } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; -import { canManageUsers } from "@/lib/domain"; +import { + APPROVAL_FLOW, + canManageUsers, + getLegacyApprovalPreference, + normalizeApprovalPermissions +} from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; +const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); +const approvalPermissionSchema = z.enum(APPROVAL_FLOW); + +const updateUserSchema = z.object({ + role: userRoleSchema, + workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), + approvalPermissions: z.array(approvalPermissionSchema).default([]) +}); + type Context = { params: { id: string; }; }; +export async function PATCH(request: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageUsers(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Nutzer bearbeiten." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = updateUserSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte Rolle, AG und Freigaberollen korrekt angeben." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: params.id } + }); + + if (!user) { + return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 }); + } + + const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0 + ? parsed.data.workingGroupId + : null; + + if (parsed.data.role === "MEMBER" && !workingGroupId) { + return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 }); + } + + if (workingGroupId) { + const workingGroup = await prisma.workingGroup.findUnique({ + where: { id: workingGroupId } + }); + + if (!workingGroup) { + return NextResponse.json({ error: "Die ausgewaehlte AG wurde nicht gefunden." }, { status: 404 }); + } + } + + if (user.role === "ADMIN" && parsed.data.role !== "ADMIN") { + const adminCount = await prisma.user.count({ + where: { role: "ADMIN" } + }); + + if (adminCount <= 1) { + return NextResponse.json({ error: "Mindestens ein Vorstandskonto muss erhalten bleiben." }, { status: 400 }); + } + } + + const approvalPermissions = normalizeApprovalPermissions(parsed.data.role, parsed.data.approvalPermissions, null); + const approvalPreference = getLegacyApprovalPreference(approvalPermissions); + const previousSnapshot = snapshotUser(user); + + const updatedUser = await prisma.user.update({ + where: { id: params.id }, + data: { + role: parsed.data.role, + workingGroupId, + approvalPreference, + approvalPermissions + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "user.update", + entityType: "user", + entityId: updatedUser.id, + entityLabel: updatedUser.username, + summary: `Nutzer ${updatedUser.username} wurde aktualisiert.`, + metadata: { + workingGroupId: updatedUser.workingGroupId, + role: updatedUser.role, + approvalPermissions: updatedUser.approvalPermissions, + rollback: { + kind: "user.update", + previous: previousSnapshot + } + } + }); + + return NextResponse.json({ ok: true }); +} + export async function DELETE(_: Request, { params }: Context) { const viewer = await getCurrentViewer(); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 90a48ef..9f0822a 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,19 +4,24 @@ import { z } from "zod"; import { snapshotUser } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; -import { canManageUsers } from "@/lib/domain"; +import { + APPROVAL_FLOW, + canManageUsers, + getLegacyApprovalPreference, + normalizeApprovalPermissions +} from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); -const approvalPreferenceSchema = z.enum(["CHAIR_A", "CHAIR_B", "FINANCE"]); +const approvalPermissionSchema = z.enum(APPROVAL_FLOW); const createUserSchema = z.object({ username: z.string().trim().min(2).max(40), password: z.string().min(8).max(128), role: userRoleSchema, workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), - approvalPreference: z.union([approvalPreferenceSchema, z.literal(""), z.null(), z.undefined()]) + approvalPermissions: z.array(approvalPermissionSchema).default([]) }); export async function POST(request: Request) { @@ -41,12 +46,11 @@ export async function POST(request: Request) { const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0 ? parsed.data.workingGroupId : null; - const requestedApprovalPreference = - parsed.data.approvalPreference === "CHAIR_A" || - parsed.data.approvalPreference === "CHAIR_B" || - parsed.data.approvalPreference === "FINANCE" - ? parsed.data.approvalPreference - : null; + const approvalPermissions = normalizeApprovalPermissions( + parsed.data.role, + parsed.data.approvalPermissions, + null + ); if (parsed.data.role === "MEMBER" && !workingGroupId) { return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 }); @@ -71,12 +75,7 @@ export async function POST(request: Request) { } const passwordHash = await bcrypt.hash(parsed.data.password, 12); - const approvalPreference = - parsed.data.role === "FINANCE" - ? "FINANCE" - : parsed.data.role === "ADMIN" - ? requestedApprovalPreference - : null; + const approvalPreference = getLegacyApprovalPreference(approvalPermissions); const user = await prisma.user.create({ data: { @@ -85,8 +84,9 @@ export async function POST(request: Request) { email: null, passwordHash, role: parsed.data.role, - workingGroupId: parsed.data.role === "MEMBER" ? workingGroupId : null, - approvalPreference + workingGroupId, + approvalPreference, + approvalPermissions } }); @@ -100,6 +100,7 @@ export async function POST(request: Request) { metadata: { role: user.role, workingGroupId: user.workingGroupId, + approvalPermissions: user.approvalPermissions, rollback: { kind: "user.create", created: snapshotUser(user) diff --git a/src/app/page.tsx b/src/app/page.tsx index cff0e4c..edb6d10 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { DashboardShell } from "@/components/dashboard/dashboard-shell"; import { getCurrentAccountingPeriod } from "@/lib/accounting-periods"; +import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; import { getRollbackMetadata } from "@/lib/audit-log"; import type { DashboardAccountingPeriod, @@ -10,7 +11,7 @@ import type { DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; -import { canManageUsers } from "@/lib/domain"; +import { canManageUsers, normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; @@ -23,7 +24,10 @@ export default async function DashboardPage() { redirect("/login"); } - const currentPeriod = await getCurrentAccountingPeriod(); + const [currentPeriod, appSettings] = await Promise.all([ + getCurrentAccountingPeriod(), + getAppSettings() + ]); if (!currentPeriod) { throw new Error("Kein Abrechnungszeitraum gefunden."); @@ -138,7 +142,11 @@ export default async function DashboardPage() { username: viewer.username, role: viewer.role, workingGroupId: viewer.workingGroupId, - approvalPreference: viewer.approvalPreference + approvalPermissions: normalizeApprovalPermissions( + viewer.role, + viewer.approvalPermissions, + viewer.approvalPreference + ) }; const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({ @@ -194,7 +202,11 @@ export default async function DashboardPage() { role: user.role, workingGroupId: user.workingGroupId, workingGroupName: user.workingGroup?.name ?? null, - approvalPreference: user.approvalPreference, + approvalPermissions: normalizeApprovalPermissions( + user.role, + user.approvalPermissions, + user.approvalPreference + ), createdExpensesCount: user._count.createdExpenses, approvalsCount: user._count.approvals })); @@ -234,6 +246,7 @@ export default async function DashboardPage() { auditLogs={serializedAuditLogs} accountingPeriods={serializedPeriods} currentPeriodId={currentPeriod.id} + approvalThreshold={toApprovalThresholdNumber(appSettings.approvalThreshold)} /> ); } diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index a90dcdc..56f9163 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -42,6 +42,7 @@ type BudgetColumnProps = { group: DashboardWorkingGroup; viewer: DashboardViewer; busy: boolean; + approvalThreshold: number; onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; @@ -51,7 +52,6 @@ type BudgetColumnProps = { onDeleteBudget: (budgetId: string) => Promise; onDeleteExpense: (expenseId: string) => Promise; }; - type BudgetDraft = { name: string; totalBudget: string; @@ -125,6 +125,7 @@ export function BudgetColumn({ group, viewer, busy, + approvalThreshold, onApprove, onMarkPaid, onDocument, @@ -471,7 +472,7 @@ export function BudgetColumn({ sx={{ ...wrappingChipSx, width: "fit-content" }} /> - {"Unter 50 EUR werden sofort freigegeben. Gr\u00f6\u00dfere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen."} + {`Unter ${formatCurrency(approvalThreshold)} werden sofort freigegeben. Groessere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen.`} @@ -563,8 +564,8 @@ export function BudgetColumn({ {budget.expenses.map((expense) => { const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType); - const availableApprovals = requiresManualApproval(expense.amount) - ? getAvailableApprovalTypes(viewer.role, viewer.approvalPreference, doneApprovalTypes) + const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold) + ? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes) : []; return ( @@ -602,7 +603,7 @@ export function BudgetColumn({ ) : null} - {requiresManualApproval(expense.amount) ? ( + {requiresManualApproval(expense.amount, approvalThreshold) ? ( {APPROVAL_FLOW.map((approvalType) => { const matchingApproval = expense.approvals.find( diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 29334e2..884ca5d 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -1,8 +1,9 @@ -"use client"; +"use client"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import EditRoundedIcon from "@mui/icons-material/EditRounded"; import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; @@ -40,9 +41,11 @@ import type { DashboardWorkingGroup } from "@/lib/dashboard-types"; import { - AUTO_APPROVAL_THRESHOLD, + APPROVAL_FLOW, + approvalLabel, canManageBudgets, canManageUsers, + getDefaultApprovalPermissionsForRole, roleLabel } from "@/lib/domain"; @@ -53,6 +56,7 @@ type DashboardShellProps = { auditLogs: DashboardAuditLog[]; accountingPeriods: DashboardAccountingPeriod[]; currentPeriodId: string; + approvalThreshold: number; }; type ExpenseFormState = { @@ -76,12 +80,20 @@ type WorkingGroupFormState = { name: string; }; +type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number]; + type UserFormState = { username: string; password: string; role: "ADMIN" | "FINANCE" | "MEMBER"; workingGroupId: string; - approvalPreference: "" | "CHAIR_A" | "CHAIR_B"; + approvalPermissions: ApprovalPermissionValue[]; +}; + +type ManagedUserDraft = { + role: "ADMIN" | "FINANCE" | "MEMBER"; + workingGroupId: string; + approvalPermissions: ApprovalPermissionValue[]; }; type PeriodFormState = { @@ -96,9 +108,21 @@ type DashboardMessage = { text: string; }; +function sortApprovalPermissions(value: ApprovalPermissionValue[]) { + return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType)); +} + +function toggleApprovalPermission( + currentValue: ApprovalPermissionValue[], + approvalType: ApprovalPermissionValue +) { + return currentValue.includes(approvalType) + ? currentValue.filter((entry) => entry !== approvalType) + : sortApprovalPermissions([...currentValue, approvalType]); +} + type MobileSection = "overview" | "actions"; type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs"; - const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" @@ -171,7 +195,8 @@ export function DashboardShell({ managedUsers, auditLogs, accountingPeriods, - currentPeriodId + currentPeriodId, + approvalThreshold }: DashboardShellProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -224,7 +249,7 @@ export function DashboardShell({ password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "", - approvalPreference: "" + approvalPermissions: [] }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); @@ -236,9 +261,11 @@ export function DashboardShell({ ); const [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); + const [editingUserId, setEditingUserId] = useState(null); const [passwordDrafts, setPasswordDrafts] = useState>({}); + const [userDrafts, setUserDrafts] = useState>({}); + const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); - useEffect(() => { if (visibleGroups.length === 0) { setSelectedMobileGroupId(""); @@ -321,10 +348,6 @@ export function DashboardShell({ }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]); useEffect(() => { - if (userForm.role !== "MEMBER") { - return; - } - const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId); if (!groupStillExists) { @@ -333,8 +356,17 @@ export function DashboardShell({ workingGroupId: visibleGroups[0]?.id ?? "" })); } - }, [userForm.role, userForm.workingGroupId, visibleGroups]); + }, [userForm.workingGroupId, visibleGroups]); + useEffect(() => { + setApprovalThresholdDraft(approvalThreshold.toFixed(2)); + }, [approvalThreshold]); + + useEffect(() => { + if (editingUserId && !managedUsers.some((user) => user.id === editingUserId)) { + setEditingUserId(null); + } + }, [editingUserId, managedUsers]); const selectedExpenseGroup = editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; @@ -344,6 +376,34 @@ export function DashboardShell({ const selectedPeriodForManagement = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; + function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft { + return userDrafts[user.id] ?? { + role: user.role, + workingGroupId: user.workingGroupId ?? "", + approvalPermissions: sortApprovalPermissions(user.approvalPermissions) + }; + } + + function updateManagedUserDraft(user: DashboardManagedUser, patch: Partial) { + setUserDrafts((current) => ({ + ...current, + [user.id]: { + ...getManagedUserDraft(user), + ...patch + } + })); + } + + function resetManagedUserDraft(user: DashboardManagedUser) { + setUserDrafts((current) => ({ + ...current, + [user.id]: { + role: user.role, + workingGroupId: user.workingGroupId ?? "", + approvalPermissions: sortApprovalPermissions(user.approvalPermissions) + } + })); + } const totals = useMemo(() => { return visibleGroups.reduce( (summary, group) => { @@ -693,8 +753,8 @@ export function DashboardShell({ username: createdUsername, password: userForm.password, role: userForm.role, - workingGroupId: userForm.role === "MEMBER" ? userForm.workingGroupId : "", - approvalPreference: userForm.role === "ADMIN" ? userForm.approvalPreference : "" + workingGroupId: userForm.workingGroupId, + approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions) }) }) ); @@ -704,7 +764,7 @@ export function DashboardShell({ password: "", role: "MEMBER", workingGroupId: visibleGroups[0]?.id ?? "", - approvalPreference: "" + approvalPermissions: [] }); return { @@ -713,10 +773,57 @@ export function DashboardShell({ }; }, ({ createdUsername, createdPassword }) => - `Nutzer wurde angelegt. Startpasswort f\u00fcr ${createdUsername}: ${createdPassword}` + `Nutzer wurde angelegt. Startpasswort fuer ${createdUsername}: ${createdPassword}` ); } + async function handleUpdateUser(user: DashboardManagedUser) { + const draft = getManagedUserDraft(user); + + await runAction(async () => { + await parseResponse( + await fetch(`/api/users/${user.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + role: draft.role, + workingGroupId: draft.workingGroupId, + approvalPermissions: sortApprovalPermissions(draft.approvalPermissions) + }) + }) + ); + + setEditingUserId(null); + }, `Nutzer ${user.username} wurde aktualisiert.`); + } + + async function handleSaveApprovalThreshold() { + const nextThreshold = Number(approvalThresholdDraft.replace(",", ".")); + + if (!Number.isFinite(nextThreshold) || nextThreshold < 0) { + setMessage({ + type: "error", + text: "Bitte eine gueltige Freigabe-Schwelle eingeben." + }); + return; + } + + await runAction(async () => { + await parseResponse( + await fetch("/api/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + approvalThreshold: nextThreshold + }) + }) + ); + }, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`); + } async function handleDeleteUser(userId: string) { await runAction(async () => { await parseResponse( @@ -768,7 +875,7 @@ export function DashboardShell({ if (!backupFile) { setMessage({ type: "error", - text: "Bitte zuerst eine CSV-Datei auswählen." + text: "Bitte zuerst eine CSV-Datei auswählen." }); return; } @@ -794,7 +901,7 @@ export function DashboardShell({ } async function handleRestoreAuditLog(entryId: string, summary: string) { - if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) { + if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) { return; } @@ -804,7 +911,7 @@ export function DashboardShell({ method: "POST" }) ); - }, "Änderung wurde zurückgesetzt."); + }, "Änderung wurde zurückgesetzt."); } function openPasswordReset(userId: string) { @@ -815,6 +922,44 @@ export function DashboardShell({ })); } + function openUserEditor(user: DashboardManagedUser) { + resetManagedUserDraft(user); + setEditingUserId(user.id); + } + + function renderApprovalPermissionSelector( + value: ApprovalPermissionValue[], + onToggle: (approvalType: ApprovalPermissionValue) => void, + helperText: string + ) { + return ( + + + Freigaberollen + + + {APPROVAL_FLOW.map((approvalType) => { + const selected = value.includes(approvalType); + + return ( + + ); + })} + + + {helperText} + + + ); + } const islandCardSx = { borderRadius: { xs: "24px", md: "30px" }, border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, @@ -838,7 +983,7 @@ export function DashboardShell({ Zeitraum wechseln - {"Nur Vorstand und Finanz-AG k\u00f6nnen die aktuelle \u00dcbersicht global umstellen."} + {"Nur Vorstand und Finanz-AG koennen die aktuelle Uebersicht global umstellen."} setSelectedCurrentPeriodId(event.target.value)} fullWidth + InputLabelProps={{ shrink: true }} sx={{ minWidth: 0 }} > {accountingPeriods.map((period) => ( @@ -870,12 +1016,9 @@ export function DashboardShell({ variant="contained" disabled={busy || selectedCurrentPeriodId === currentPeriodId} onClick={handleSetCurrentPeriod} - sx={{ - minWidth: 0, - px: { sm: 1.75 } - }} + sx={{ minWidth: 0, minHeight: 56, px: 2 }} > - {"\u00dcbersicht setzen"} + {"Uebersicht setzen"} {selectedPeriodForManagement?.isCurrent - ? "Der aktuell aktive Zeitraum kann nicht gel\u00f6scht werden." - : "Leere, nicht aktive Zeitr\u00e4ume lassen sich hier wieder entfernen."} + ? "Der aktuell aktive Zeitraum kann nicht geloescht werden." + : "Leere, nicht aktive Zeitraeume lassen sich hier wieder entfernen."} @@ -943,7 +1087,7 @@ export function DashboardShell({ setPeriodForm((current) => ({ @@ -952,9 +1096,9 @@ export function DashboardShell({ })) } fullWidth - helperText={"Optional kopiert die vorhandenen Budgett\u00f6pfe direkt in den neuen Zeitraum."} - > - {`Ohne Budget\u00fcbernahme`} + helperText={"Optional kopiert die vorhandenen Budgettoepfe direkt in den neuen Zeitraum."} + > + Ohne Budgetuebernahme {accountingPeriods.map((period) => ( {period.name} @@ -968,7 +1112,6 @@ export function DashboardShell({ ) : null; - const actionCards = ( {"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "} - {AUTO_APPROVAL_THRESHOLD} + {approvalThreshold.toFixed(2)} {" EUR wird automatisch freigegeben."} @@ -1238,7 +1381,7 @@ export function DashboardShell({ CSV-Backup - {"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."} + {"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."} @@ -1251,7 +1394,7 @@ export function DashboardShell({ CSV herunterladen - - - setUserForm((current) => ({ - ...current, - role: event.target.value as UserFormState["role"], - approvalPreference: event.target.value === "ADMIN" ? current.approvalPreference : "", - workingGroupId: event.target.value === "MEMBER" ? current.workingGroupId : "" - })) - } - required - > - Vorstand - Finanz-AG - AG-Mitglied - - - {userForm.role === "MEMBER" ? ( + + + setUserForm((current) => ({ ...current, password: event.target.value })) + } + required + fullWidth + helperText={"Dieses Passwort wird nach dem Anlegen oben als Bestaetigung angezeigt."} + /> + + { + const nextRole = event.target.value as UserFormState["role"]; + setUserForm((current) => ({ + ...current, + role: nextRole, + approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole)) + })); + }} + required + > + Vorstand + Finanz-AG + AG-Mitglied + + setUserForm((current) => ({ ...current, workingGroupId: event.target.value })) } - required + fullWidth + disabled={visibleGroups.length === 0} + required={userForm.role === "MEMBER"} + helperText={ + visibleGroups.length === 0 + ? "Lege zuerst eine AG an." + : userForm.role === "MEMBER" + ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." + : "Optional: Auch Vorstand und Finanz-AG koennen einer AG zugeordnet werden." + } > + {userForm.role !== "MEMBER" ? Ohne AG : null} {visibleGroups.map((group) => ( {group.name} ))} - ) : null} - - {userForm.role === "ADMIN" ? ( - + {renderApprovalPermissionSelector( + userForm.approvalPermissions, + (approvalType) => setUserForm((current) => ({ ...current, - approvalPreference: event.target.value as UserFormState["approvalPreference"] - })) - } - > - Keine Voreinstellung - Vorstand A - Vorstand B - - ) : null} + approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType) + })), + "Lege fest, fuer welche Freigabeschritte dieses Konto zeichnen darf." + )} + + + + + + - - - - - - + + + + + + Freigabe-Schwelle + + + {"Ausgaben unter diesem Betrag werden automatisch freigegeben."} + + + setApprovalThresholdDraft(event.target.value)} + helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`} + fullWidth + /> + + + + + ) : null} - {canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( @@ -1398,13 +1566,15 @@ export function DashboardShell({ Nutzer verwalten - {"Bestehende Passw\u00f6rter bleiben sicher gehasht und sind nicht auslesbar. Hier kannst du neue setzen und direkt sehen."} + {"Bestehende Passwoerter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung, Freigaberollen und Passwoerter pflegen."} {managedUsers.map((user) => { const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0; const isResetOpen = editingPasswordUserId === user.id; + const isEditingUser = editingUserId === user.id; + const draft = getManagedUserDraft(user); return ( @@ -1422,6 +1592,22 @@ export function DashboardShell({ + @@ -1460,8 +1646,93 @@ export function DashboardShell({ + {user.approvalPermissions.length > 0 ? ( + user.approvalPermissions.map((approvalType) => ( + + )) + ) : ( + + )} + {isEditingUser ? ( + + + { + const nextRole = event.target.value as ManagedUserDraft["role"]; + updateManagedUserDraft(user, { + role: nextRole, + approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole)) + }); + }} + fullWidth + > + Vorstand + Finanz-AG + AG-Mitglied + + updateManagedUserDraft(user, { workingGroupId: event.target.value })} + fullWidth + disabled={visibleGroups.length === 0} + required={draft.role === "MEMBER"} + helperText={ + visibleGroups.length === 0 + ? "Lege zuerst eine AG an." + : draft.role === "MEMBER" + ? "AG-Mitglieder brauchen eine feste AG-Zuordnung." + : "Optional: Auch Vorstand und Finanz-AG koennen einer AG zugeordnet werden." + } + > + {draft.role !== "MEMBER" ? Ohne AG : null} + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + {renderApprovalPermissionSelector( + draft.approvalPermissions, + (approvalType) => + updateManagedUserDraft(user, { + approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType) + }), + "Lege fest, welche Freigabeschritte dieses Konto autorisieren darf." + )} + + + + + + + ) : null} + {isResetOpen ? ( @@ -1529,13 +1800,6 @@ export function DashboardShell({ ) : null} - - {canManagePeriods && isCompactLayout ? ( - - {periodManagementPanel} - - ) : null} - {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( @@ -1585,7 +1849,7 @@ export function DashboardShell({ onClick={() => handleRestoreAuditLog(entry.id, entry.summary)} sx={{ alignSelf: "flex-start" }} > - Zustand zurücksetzen + Zustand zurücksetzen ) : null} @@ -1687,14 +1951,14 @@ export function DashboardShell({ group={group} viewer={viewer} busy={busy} + approvalThreshold={approvalThreshold} onApprove={handleApprove} onMarkPaid={handleMarkPaid} onDocument={handleDocument} onSaveWorkingGroup={handleSaveWorkingGroup} onDeleteWorkingGroup={handleDeleteWorkingGroup} onSaveBudget={handleSaveBudget} - onDeleteBudget={handleDeleteBudget} - onDeleteExpense={handleDeleteExpense} + onDeleteBudget={handleDeleteBudget} onDeleteExpense={handleDeleteExpense} /> ))} @@ -1710,7 +1974,7 @@ export function DashboardShell({ ) : desktopSection === "periods" ? ( {canManagePeriods ? ( - + {periodManagementPanel} ) : null} diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts new file mode 100644 index 0000000..61ff4e9 --- /dev/null +++ b/src/lib/app-settings.ts @@ -0,0 +1,24 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +import { DEFAULT_APPROVAL_THRESHOLD } from "@/lib/domain"; +import prisma from "@/lib/prisma"; + +type SettingsClient = PrismaClient | Prisma.TransactionClient; + +export async function getAppSettings(client: SettingsClient = prisma) { + return client.appSettings.upsert({ + where: { + id: "global" + }, + update: {}, + create: { + id: "global", + approvalThreshold: DEFAULT_APPROVAL_THRESHOLD + } + }); +} + +export function toApprovalThresholdNumber(value: { toString(): string } | number | string) { + const parsed = Number(typeof value === "number" ? value : value.toString()); + return Number.isFinite(parsed) ? parsed : DEFAULT_APPROVAL_THRESHOLD; +} diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts index e2c84f6..9af68cb 100644 --- a/src/lib/audit-snapshots.ts +++ b/src/lib/audit-snapshots.ts @@ -1,4 +1,4 @@ -import type { Approval, AccountingPeriod, Budget, Expense, User, WorkingGroup } from "@prisma/client"; +import type { AppSettings, Approval, AccountingPeriod, Budget, Expense, User, WorkingGroup } from "@prisma/client"; export function snapshotWorkingGroup(workingGroup: Pick) { return { @@ -19,6 +19,14 @@ export function snapshotPeriod(period: Pick) { + return { + id: settings.id, + approvalThreshold: Number(settings.approvalThreshold), + createdAt: settings.createdAt.toISOString() + }; +} + export function snapshotBudget(budget: Pick) { return { id: budget.id, @@ -81,7 +89,16 @@ export function snapshotApproval(approval: Pick ) { return { @@ -92,6 +109,7 @@ export function snapshotUser( passwordHash: user.passwordHash, role: user.role, approvalPreference: user.approvalPreference, + approvalPermissions: user.approvalPermissions, workingGroupId: user.workingGroupId, createdAt: user.createdAt.toISOString() }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 574c578..8906fb2 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { z } from "zod"; +import { normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; const credentialsSchema = z.object({ @@ -55,7 +56,11 @@ export const authOptions: NextAuthOptions = { email: matchedUser.email, role: matchedUser.role, workingGroupId: matchedUser.workingGroupId, - approvalPreference: matchedUser.approvalPreference + approvalPermissions: normalizeApprovalPermissions( + matchedUser.role, + matchedUser.approvalPermissions, + matchedUser.approvalPreference + ) }; } }) @@ -67,7 +72,7 @@ export const authOptions: NextAuthOptions = { token.username = user.username; token.role = user.role; token.workingGroupId = user.workingGroupId; - token.approvalPreference = user.approvalPreference; + token.approvalPermissions = user.approvalPermissions; } return token; @@ -78,7 +83,7 @@ export const authOptions: NextAuthOptions = { session.user.username = token.username ?? ""; session.user.role = token.role ?? "MEMBER"; session.user.workingGroupId = token.workingGroupId ?? null; - session.user.approvalPreference = token.approvalPreference ?? null; + session.user.approvalPermissions = token.approvalPermissions ?? []; } return session; diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index e0d3a46..7be27ef 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -14,7 +14,7 @@ export type DashboardViewer = { username: string; role: AppRole; workingGroupId: string | null; - approvalPreference: ApprovalTypeValue | null; + approvalPermissions: ApprovalTypeValue[]; }; export type DashboardApproval = { @@ -76,7 +76,7 @@ export type DashboardManagedUser = { role: AppRole; workingGroupId: string | null; workingGroupName: string | null; - approvalPreference: ApprovalTypeValue | null; + approvalPermissions: ApprovalTypeValue[]; createdExpensesCount: number; approvalsCount: number; }; diff --git a/src/lib/domain.ts b/src/lib/domain.ts index 9679a11..f012c66 100644 --- a/src/lib/domain.ts +++ b/src/lib/domain.ts @@ -1,4 +1,4 @@ -export const AUTO_APPROVAL_THRESHOLD = 50; +export const DEFAULT_APPROVAL_THRESHOLD = 50; export const APPROVAL_FLOW = ["CHAIR_A", "CHAIR_B", "FINANCE"] as const; export const COLOR_PRESETS = [ @@ -19,8 +19,8 @@ export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number]; export type ApprovalStatusValue = "PENDING" | "APPROVED"; export type ExpenseRecurrenceValue = "NONE" | "MONTHLY"; -export function requiresManualApproval(amount: number) { - return amount >= AUTO_APPROVAL_THRESHOLD; +export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) { + return amount >= approvalThreshold; } export function roleLabel(role: AppRole) { @@ -97,26 +97,42 @@ export function canDeleteExpense( return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt; } -export function getAvailableApprovalTypes( +export function getDefaultApprovalPermissionsForRole(role: AppRole): ApprovalTypeValue[] { + switch (role) { + case "ADMIN": + return ["CHAIR_A"]; + case "FINANCE": + return ["FINANCE"]; + case "MEMBER": + return []; + } +} + +export function normalizeApprovalPermissions( role: AppRole, - approvalPreference: ApprovalTypeValue | null | undefined, + approvalPermissions: ApprovalTypeValue[] | null | undefined, + approvalPreference: ApprovalTypeValue | null | undefined = null +) { + const rawPermissions = approvalPermissions && approvalPermissions.length > 0 + ? approvalPermissions + : approvalPreference + ? [approvalPreference] + : getDefaultApprovalPermissionsForRole(role); + + return APPROVAL_FLOW.filter( + (approvalType, index) => rawPermissions.includes(approvalType) && rawPermissions.indexOf(approvalType) === index + ) as ApprovalTypeValue[]; +} + +export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeValue[]) { + return approvalPermissions[0] ?? null; +} + +export function getAvailableApprovalTypes( + approvalPermissions: ApprovalTypeValue[], existingApprovals: ApprovalTypeValue[] ): ApprovalTypeValue[] { - const missingApprovals = APPROVAL_FLOW.filter( - (approvalType) => !existingApprovals.includes(approvalType) + return APPROVAL_FLOW.filter( + (approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType) ) as ApprovalTypeValue[]; - - if (role === "ADMIN") { - if (approvalPreference && missingApprovals.includes(approvalPreference)) { - return [approvalPreference, ...missingApprovals.filter((approvalType) => approvalType !== approvalPreference)]; - } - - return missingApprovals; - } - - if (role === "FINANCE") { - return missingApprovals.includes("FINANCE") ? ["FINANCE"] : []; - } - - return []; } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 908a6f6..15f9b81 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -8,7 +8,7 @@ declare module "next-auth" { username: string; role: Role; workingGroupId: string | null; - approvalPreference: ApprovalType | null; + approvalPermissions: ApprovalType[]; } & DefaultSession["user"]; } @@ -17,7 +17,7 @@ declare module "next-auth" { username: string; role: Role; workingGroupId: string | null; - approvalPreference: ApprovalType | null; + approvalPermissions: ApprovalType[]; } } @@ -27,6 +27,6 @@ declare module "next-auth/jwt" { username?: string; role?: Role; workingGroupId?: string | null; - approvalPreference?: ApprovalType | null; + approvalPermissions?: ApprovalType[]; } }