In der Nutzerverwaltung kannst du jetzt pro Konto die Rolle, die AG-Zuordnung und die Freigaberollen bearbeiten. Die feste 3er-Freigabelogik bleibt Vorstand A / Vorstand B / Finanz-AG, aber jetzt legst du über die Nutzer fest, wer diese Schritte autorisieren darf. Zusätzlich gibt es unter Nutzer anlegen eine eigene Insel für die Freigabe-Schwelle, und diese Schwelle wird jetzt auch wirklich überall verwendet: in der Erfassungslogik, in den Budgetkarten, im CSV-Backup/-Import und im Audit-Restore. Die Hauptänderungen sitzen in dashboard-shell.tsx, budget-column.tsx, route.ts, schema.prisma und route.ts.
All checks were successful
CI / build-and-deploy (push) Successful in 1m22s

Den Zeitraum-Bereich habe ich dabei gleich mit aufgeräumt: die Auswahl des aktuellen Haushalts ist breiter und sauberer angeordnet, und die Desktop-Nutzerverwaltung ist jetzt wirklich links Anlegen + Schwelle und rechts die Nutzerliste. Seed und Backup/Restore kennen die neuen Felder ebenfalls in seed.ts, route.ts und route.ts.
This commit is contained in:
Jan
2026-04-12 20:09:46 +02:00
parent 92d96ffa27
commit b202fc6c26
20 changed files with 1018 additions and 365 deletions

View File

@@ -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;

View File

@@ -30,20 +30,21 @@ enum ExpenseRecurrence {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
username String @unique username String @unique
email String? @unique email String? @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
role Role role Role
approvalPreference ApprovalType? @map("approval_preference") approvalPreference ApprovalType? @map("approval_preference")
workingGroupId String? @map("working_group_id") approvalPermissions ApprovalType[] @default([]) @map("approval_permissions")
workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull) workingGroupId String? @map("working_group_id")
createdExpenses Expense[] @relation("ExpenseCreator") workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull)
approvals Approval[] createdExpenses Expense[] @relation("ExpenseCreator")
auditLogs AuditLog[] approvals Approval[]
createdAt DateTime @default(now()) @map("created_at") auditLogs AuditLog[]
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users") @@map("users")
} }
@@ -62,6 +63,15 @@ model AccountingPeriod {
@@map("accounting_periods") @@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 { model WorkingGroup {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
@@ -75,42 +85,42 @@ model WorkingGroup {
} }
model Budget { model Budget {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
totalBudget Decimal @db.Decimal(10, 2) @map("total_budget") totalBudget Decimal @db.Decimal(10, 2) @map("total_budget")
colorCode String @map("color_code") colorCode String @map("color_code")
workingGroupId String @map("working_group_id") workingGroupId String @map("working_group_id")
periodId String @map("period_id") periodId String @map("period_id")
workingGroup WorkingGroup @relation(fields: [workingGroupId], references: [id], onDelete: Cascade) workingGroup WorkingGroup @relation(fields: [workingGroupId], references: [id], onDelete: Cascade)
period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict)
expenses Expense[] expenses Expense[]
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")
@@unique([workingGroupId, periodId, name]) @@unique([workingGroupId, periodId, name])
@@map("budgets") @@map("budgets")
} }
model Expense { model Expense {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
description String? description String?
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
creatorId String @map("creator_id") creatorId String @map("creator_id")
agId String @map("ag_id") agId String @map("ag_id")
budgetId String @map("budget_id") budgetId String @map("budget_id")
periodId String @map("period_id") periodId String @map("period_id")
approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") approvalStatus ApprovalStatus @default(PENDING) @map("approval_status")
recurrence ExpenseRecurrence @default(NONE) recurrence ExpenseRecurrence @default(NONE)
paidAt DateTime? @map("paid_at") paidAt DateTime? @map("paid_at")
documentedAt DateTime? @map("documented_at") documentedAt DateTime? @map("documented_at")
proofUrl String? @map("proof_url") 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)
workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade) workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade)
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[]
@@map("expenses") @@map("expenses")

View File

@@ -3,6 +3,21 @@ import bcrypt from "bcryptjs";
const prisma = new PrismaClient(); 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() { async function upsertCurrentPeriod() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const startsAt = new Date(Date.UTC(year, 0, 1)); const startsAt = new Date(Date.UTC(year, 0, 1));
@@ -36,9 +51,7 @@ async function upsertWorkingGroup(name: string) {
return prisma.workingGroup.upsert({ return prisma.workingGroup.upsert({
where: { name }, where: { name },
update: {}, update: {},
create: { create: { name }
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() { async function main() {
const passwordHash = await bcrypt.hash("demo123!", 12); const passwordHash = await bcrypt.hash("demo123!", 12);
await upsertAppSettings();
const currentPeriod = await upsertCurrentPeriod(); const currentPeriod = await upsertCurrentPeriod();
const deko = await upsertWorkingGroup("AG Deko"); const deko = await upsertWorkingGroup("AG Deko");
@@ -83,109 +132,41 @@ async function main() {
await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D"); await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D");
const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6"); const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6");
await prisma.user.upsert({ await upsertUser({
where: { username: "vorstand-a" }, username: "vorstand-a",
update: { role: Role.ADMIN,
name: "Admin 1", passwordHash,
username: "vorstand-a", approvalPermissions: [ApprovalType.CHAIR_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 prisma.user.upsert({ await upsertUser({
where: { username: "vorstand-b" }, username: "vorstand-b",
update: { role: Role.ADMIN,
name: "Admin 2", passwordHash,
username: "vorstand-b", approvalPermissions: [ApprovalType.CHAIR_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 prisma.user.upsert({ await upsertUser({
where: { username: "finanzen" }, username: "finanzen",
update: { role: Role.FINANCE,
name: "Finanz-AG", passwordHash,
username: "finanzen", approvalPermissions: [ApprovalType.FINANCE]
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 prisma.user.upsert({ await upsertUser({
where: { username: "deko" }, username: "deko",
update: { role: Role.MEMBER,
name: "Deko Mitglied", passwordHash,
username: "deko", workingGroupId: deko.id,
email: null, approvalPermissions: []
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 prisma.user.upsert({ await upsertUser({
where: { username: "technik" }, username: "technik",
update: { role: Role.MEMBER,
name: "Technik Mitglied", passwordHash,
username: "technik", workingGroupId: technik.id,
email: null, approvalPermissions: []
passwordHash,
role: Role.MEMBER,
approvalPreference: null,
workingGroupId: technik.id
},
create: {
name: "Technik Mitglied",
username: "technik",
email: null,
passwordHash,
role: Role.MEMBER,
workingGroupId: technik.id
}
}); });
const existingExpense = await prisma.expense.findFirst({ const existingExpense = await prisma.expense.findFirst({
@@ -247,4 +228,4 @@ main()
console.error(error); console.error(error);
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });

View File

@@ -53,6 +53,17 @@ function asNumber(value: unknown, label: string) {
return value; 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) { export async function POST(_: Request, { params }: Context) {
const viewer = await getCurrentViewer(); const viewer = await getCurrentViewer();
@@ -306,6 +317,25 @@ export async function POST(_: Request, { params }: Context) {
break; 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": { case "user.create": {
const created = asRecord(rollback.created, "Nutzer"); const created = asRecord(rollback.created, "Nutzer");
const userId = asString(created.id, "Nutzer-ID"); const userId = asString(created.id, "Nutzer-ID");
@@ -364,6 +394,7 @@ export async function POST(_: Request, { params }: Context) {
passwordHash: asString(deleted.passwordHash, "Passworthash"), passwordHash: asString(deleted.passwordHash, "Passworthash"),
role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER", role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER",
approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null,
approvalPermissions: asApprovalPermissions(deleted.approvalPermissions),
workingGroupId: asNullableString(deleted.workingGroupId), workingGroupId: asNullableString(deleted.workingGroupId),
createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date() createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date()
} }
@@ -371,6 +402,27 @@ export async function POST(_: Request, { params }: Context) {
break; 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": { case "user.passwordReset": {
await tx.user.update({ await tx.user.update({
where: { where: {

View File

@@ -1,9 +1,15 @@
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 { snapshotApproval } from "@/lib/audit-snapshots"; import { snapshotApproval } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log"; 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 prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; 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 }); return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
} }
const expense = await prisma.expense.findUnique({ const [expense, appSettings] = await Promise.all([
where: { id: params.id }, prisma.expense.findUnique({
include: { where: { id: params.id },
approvals: true include: {
} approvals: true
}); }
}),
getAppSettings()
]);
if (!expense) { if (!expense) {
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); 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 }); 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 existingApprovals = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = getAvailableApprovalTypes( const viewerApprovalPermissions = normalizeApprovalPermissions(
viewer.role, viewer.role,
viewer.approvalPreference, viewer.approvalPermissions,
existingApprovals viewer.approvalPreference
); );
const availableApprovals = getAvailableApprovalTypes(viewerApprovalPermissions, existingApprovals);
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 });
@@ -111,6 +123,7 @@ export async function POST(request: Request, { params }: Context) {
summary: `${parsed.data.approvalType} fuer ${expense.title} wurde gesetzt.`, summary: `${parsed.data.approvalType} fuer ${expense.title} wurde gesetzt.`,
metadata: { metadata: {
approvalType: parsed.data.approvalType, approvalType: parsed.data.approvalType,
approvalThreshold,
rollback: { rollback: {
kind: "expense.approve", kind: "expense.approve",
approval: snapshotApproval(transactionResult.approval), approval: snapshotApproval(transactionResult.approval),

View File

@@ -1,6 +1,7 @@
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 { snapshotExpense } from "@/lib/audit-snapshots"; import { snapshotExpense } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log"; import { createAuditLog } from "@/lib/audit-log";
import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain"; import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
@@ -18,7 +19,7 @@ const expenseSchema = z.object({
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"), recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
proofUrl: z proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) .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) { 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 }); return NextResponse.json({ error: "Du kannst nur in deiner eigenen AG Ausgaben erfassen." }, { status: 403 });
} }
const budget = await prisma.budget.findUnique({ const [budget, appSettings] = await Promise.all([
where: { id: parsed.data.budgetId } prisma.budget.findUnique({
}); where: { id: parsed.data.budgetId }
}),
getAppSettings()
]);
if (!budget || budget.workingGroupId !== parsed.data.agId) { if (!budget || budget.workingGroupId !== parsed.data.agId) {
return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 }); return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 });
} }
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
const expense = await prisma.expense.create({ const expense = await prisma.expense.create({
data: { data: {
title: parsed.data.title, title: parsed.data.title,
@@ -58,7 +64,7 @@ export async function POST(request: Request) {
creatorId: viewer.id, creatorId: viewer.id,
proofUrl: parsed.data.proofUrl, proofUrl: parsed.data.proofUrl,
recurrence: parsed.data.recurrence, 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, workingGroupId: parsed.data.agId,
recurrence: parsed.data.recurrence, recurrence: parsed.data.recurrence,
approvalStatus: expense.approvalStatus, approvalStatus: expense.approvalStatus,
approvalThreshold,
rollback: { rollback: {
kind: "expense.create", kind: "expense.create",
created: snapshotExpense(expense) created: snapshotExpense(expense)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
import { toCsvCell } from "@/lib/backup-csv"; import { toCsvCell } from "@/lib/backup-csv";
import { canManageUsers } from "@/lib/domain"; import { canManageUsers } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
@@ -26,6 +27,8 @@ const CSV_HEADERS = [
"email", "email",
"role", "role",
"approvalPreference", "approvalPreference",
"approvalPermissions",
"approvalThreshold",
"title", "title",
"description", "description",
"amount", "amount",
@@ -65,7 +68,8 @@ export async function GET() {
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen CSV-Backups herunterladen." }, { status: 403 }); 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({ prisma.user.findMany({
include: { include: {
workingGroup: { workingGroup: {
@@ -96,7 +100,8 @@ export async function GET() {
username: true, username: true,
email: true, email: true,
role: true, role: true,
approvalPreference: true approvalPreference: true,
approvalPermissions: true
}, },
orderBy: { orderBy: {
username: "asc" username: "asc"
@@ -157,6 +162,13 @@ export async function GET() {
const rows: CsvRow[] = []; 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) { for (const user of users) {
rows.push({ rows.push({
recordType: "user", recordType: "user",
@@ -179,6 +191,8 @@ export async function GET() {
email: user.email, email: user.email,
role: user.role, role: user.role,
approvalPreference: user.approvalPreference ?? "", approvalPreference: user.approvalPreference ?? "",
approvalPermissions: user.approvalPermissions.join("|"),
approvalThreshold: "",
title: "", title: "",
description: "", description: "",
amount: "", amount: "",
@@ -228,6 +242,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: "", title: "",
description: "", description: "",
amount: "", amount: "",
@@ -277,6 +293,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: "", title: "",
description: "", description: "",
amount: "", amount: "",
@@ -325,6 +343,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: "", title: "",
description: "", description: "",
amount: "", amount: "",
@@ -373,6 +393,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: expense.title, title: expense.title,
description: expense.description ?? "", description: expense.description ?? "",
amount: Number(expense.amount).toFixed(2), amount: Number(expense.amount).toFixed(2),
@@ -421,6 +443,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: expense.title, title: expense.title,
description: "", description: "",
amount: Number(expense.amount).toFixed(2), amount: Number(expense.amount).toFixed(2),
@@ -473,6 +497,8 @@ export async function GET() {
email: "", email: "",
role: "", role: "",
approvalPreference: "", approvalPreference: "",
approvalPermissions: "",
approvalThreshold: "",
title: "", title: "",
description: "", description: "",
amount: "", amount: "",

View File

@@ -2,7 +2,7 @@ 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 } from "@/lib/domain"; import { canManageUsers, DEFAULT_APPROVAL_THRESHOLD, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; import { getCurrentViewer } from "@/lib/session";
@@ -28,6 +28,21 @@ function toNumber(value: string | undefined) {
return Number.isFinite(parsed) ? parsed : null; 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) { export async function POST(request: Request) {
const viewer = await getCurrentViewer(); 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 periodRows = rawEntries.filter((entry) => entry.recordType === "period");
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");
@@ -87,6 +103,16 @@ export async function POST(request: Request) {
await tx.user.deleteMany(); await tx.user.deleteMany();
await tx.workingGroup.deleteMany(); await tx.workingGroup.deleteMany();
await tx.accountingPeriod.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) { for (const row of periodRows) {
const startsAt = toDate(row.periodStartsAt); const startsAt = toDate(row.periodStartsAt);
@@ -119,6 +145,10 @@ export async function POST(request: Request) {
} }
for (const row of userRows) { 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({ await tx.user.create({
data: { data: {
id: row.id, id: row.id,
@@ -126,8 +156,9 @@ export async function POST(request: Request) {
username: row.username, username: row.username,
email: toNullable(row.email), email: toNullable(row.email),
passwordHash: row.passwordHash, passwordHash: row.passwordHash,
role: row.role as "ADMIN" | "FINANCE" | "MEMBER", role,
approvalPreference: toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, approvalPreference: getLegacyApprovalPreference(approvalPermissions),
approvalPermissions,
workingGroupId: toNullable(row.workingGroupId), workingGroupId: toNullable(row.workingGroupId),
createdAt: toDate(row.createdAt) ?? new Date() createdAt: toDate(row.createdAt) ?? new Date()
} }

View File

@@ -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)
});
}

View File

@@ -1,17 +1,121 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod";
import { snapshotUser } from "@/lib/audit-snapshots"; import { snapshotUser } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log"; 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 prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; 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 = { type Context = {
params: { params: {
id: string; 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) { export async function DELETE(_: Request, { params }: Context) {
const viewer = await getCurrentViewer(); const viewer = await getCurrentViewer();

View File

@@ -4,19 +4,24 @@ import { z } from "zod";
import { snapshotUser } from "@/lib/audit-snapshots"; import { snapshotUser } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log"; 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 prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; import { getCurrentViewer } from "@/lib/session";
const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); 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({ const createUserSchema = z.object({
username: z.string().trim().min(2).max(40), username: z.string().trim().min(2).max(40),
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
role: userRoleSchema, role: userRoleSchema,
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), 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) { 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 const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0
? parsed.data.workingGroupId ? parsed.data.workingGroupId
: null; : null;
const requestedApprovalPreference = const approvalPermissions = normalizeApprovalPermissions(
parsed.data.approvalPreference === "CHAIR_A" || parsed.data.role,
parsed.data.approvalPreference === "CHAIR_B" || parsed.data.approvalPermissions,
parsed.data.approvalPreference === "FINANCE" null
? parsed.data.approvalPreference );
: null;
if (parsed.data.role === "MEMBER" && !workingGroupId) { if (parsed.data.role === "MEMBER" && !workingGroupId) {
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 }); 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 passwordHash = await bcrypt.hash(parsed.data.password, 12);
const approvalPreference = const approvalPreference = getLegacyApprovalPreference(approvalPermissions);
parsed.data.role === "FINANCE"
? "FINANCE"
: parsed.data.role === "ADMIN"
? requestedApprovalPreference
: null;
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
@@ -85,8 +84,9 @@ export async function POST(request: Request) {
email: null, email: null,
passwordHash, passwordHash,
role: parsed.data.role, role: parsed.data.role,
workingGroupId: parsed.data.role === "MEMBER" ? workingGroupId : null, workingGroupId,
approvalPreference approvalPreference,
approvalPermissions
} }
}); });
@@ -100,6 +100,7 @@ export async function POST(request: Request) {
metadata: { metadata: {
role: user.role, role: user.role,
workingGroupId: user.workingGroupId, workingGroupId: user.workingGroupId,
approvalPermissions: user.approvalPermissions,
rollback: { rollback: {
kind: "user.create", kind: "user.create",
created: snapshotUser(user) created: snapshotUser(user)

View File

@@ -2,6 +2,7 @@ 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 { getRollbackMetadata } from "@/lib/audit-log"; import { getRollbackMetadata } from "@/lib/audit-log";
import type { import type {
DashboardAccountingPeriod, DashboardAccountingPeriod,
@@ -10,7 +11,7 @@ import type {
DashboardViewer, DashboardViewer,
DashboardWorkingGroup DashboardWorkingGroup
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import { canManageUsers } from "@/lib/domain"; import { canManageUsers, normalizeApprovalPermissions } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; import { getCurrentViewer } from "@/lib/session";
@@ -23,7 +24,10 @@ export default async function DashboardPage() {
redirect("/login"); redirect("/login");
} }
const currentPeriod = await getCurrentAccountingPeriod(); const [currentPeriod, appSettings] = await Promise.all([
getCurrentAccountingPeriod(),
getAppSettings()
]);
if (!currentPeriod) { if (!currentPeriod) {
throw new Error("Kein Abrechnungszeitraum gefunden."); throw new Error("Kein Abrechnungszeitraum gefunden.");
@@ -138,7 +142,11 @@ export default async function DashboardPage() {
username: viewer.username, username: viewer.username,
role: viewer.role, role: viewer.role,
workingGroupId: viewer.workingGroupId, workingGroupId: viewer.workingGroupId,
approvalPreference: viewer.approvalPreference approvalPermissions: normalizeApprovalPermissions(
viewer.role,
viewer.approvalPermissions,
viewer.approvalPreference
)
}; };
const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({ const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({
@@ -194,7 +202,11 @@ export default async function DashboardPage() {
role: user.role, role: user.role,
workingGroupId: user.workingGroupId, workingGroupId: user.workingGroupId,
workingGroupName: user.workingGroup?.name ?? null, workingGroupName: user.workingGroup?.name ?? null,
approvalPreference: user.approvalPreference, approvalPermissions: normalizeApprovalPermissions(
user.role,
user.approvalPermissions,
user.approvalPreference
),
createdExpensesCount: user._count.createdExpenses, createdExpensesCount: user._count.createdExpenses,
approvalsCount: user._count.approvals approvalsCount: user._count.approvals
})); }));
@@ -234,6 +246,7 @@ export default async function DashboardPage() {
auditLogs={serializedAuditLogs} auditLogs={serializedAuditLogs}
accountingPeriods={serializedPeriods} accountingPeriods={serializedPeriods}
currentPeriodId={currentPeriod.id} currentPeriodId={currentPeriod.id}
approvalThreshold={toApprovalThresholdNumber(appSettings.approvalThreshold)}
/> />
); );
} }

View File

@@ -42,6 +42,7 @@ type BudgetColumnProps = {
group: DashboardWorkingGroup; group: DashboardWorkingGroup;
viewer: DashboardViewer; viewer: DashboardViewer;
busy: boolean; busy: boolean;
approvalThreshold: number;
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>;
@@ -51,7 +52,6 @@ type BudgetColumnProps = {
onDeleteBudget: (budgetId: string) => Promise<void>; onDeleteBudget: (budgetId: string) => Promise<void>;
onDeleteExpense: (expenseId: string) => Promise<void>; onDeleteExpense: (expenseId: string) => Promise<void>;
}; };
type BudgetDraft = { type BudgetDraft = {
name: string; name: string;
totalBudget: string; totalBudget: string;
@@ -125,6 +125,7 @@ export function BudgetColumn({
group, group,
viewer, viewer,
busy, busy,
approvalThreshold,
onApprove, onApprove,
onMarkPaid, onMarkPaid,
onDocument, onDocument,
@@ -471,7 +472,7 @@ export function BudgetColumn({
sx={{ ...wrappingChipSx, width: "fit-content" }} sx={{ ...wrappingChipSx, width: "fit-content" }}
/> />
<Typography color="text.secondary"> <Typography color="text.secondary">
{"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.`}
</Typography> </Typography>
</Stack> </Stack>
</Stack> </Stack>
@@ -563,8 +564,8 @@ export function BudgetColumn({
{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) const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.role, viewer.approvalPreference, doneApprovalTypes) ? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
: []; : [];
return ( return (
@@ -602,7 +603,7 @@ export function BudgetColumn({
</Typography> </Typography>
) : null} ) : null}
{requiresManualApproval(expense.amount) ? ( {requiresManualApproval(expense.amount, approvalThreshold) ? (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap"> <Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => { {APPROVAL_FLOW.map((approvalType) => {
const matchingApproval = expense.approvals.find( const matchingApproval = expense.approvals.find(

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import AddRoundedIcon from "@mui/icons-material/AddRounded"; import AddRoundedIcon from "@mui/icons-material/AddRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
import EditRoundedIcon from "@mui/icons-material/EditRounded";
import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
@@ -40,9 +41,11 @@ import type {
DashboardWorkingGroup DashboardWorkingGroup
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import { import {
AUTO_APPROVAL_THRESHOLD, APPROVAL_FLOW,
approvalLabel,
canManageBudgets, canManageBudgets,
canManageUsers, canManageUsers,
getDefaultApprovalPermissionsForRole,
roleLabel roleLabel
} from "@/lib/domain"; } from "@/lib/domain";
@@ -53,6 +56,7 @@ type DashboardShellProps = {
auditLogs: DashboardAuditLog[]; auditLogs: DashboardAuditLog[];
accountingPeriods: DashboardAccountingPeriod[]; accountingPeriods: DashboardAccountingPeriod[];
currentPeriodId: string; currentPeriodId: string;
approvalThreshold: number;
}; };
type ExpenseFormState = { type ExpenseFormState = {
@@ -76,12 +80,20 @@ type WorkingGroupFormState = {
name: string; name: string;
}; };
type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number];
type UserFormState = { type UserFormState = {
username: string; username: string;
password: string; password: string;
role: "ADMIN" | "FINANCE" | "MEMBER"; role: "ADMIN" | "FINANCE" | "MEMBER";
workingGroupId: string; workingGroupId: string;
approvalPreference: "" | "CHAIR_A" | "CHAIR_B"; approvalPermissions: ApprovalPermissionValue[];
};
type ManagedUserDraft = {
role: "ADMIN" | "FINANCE" | "MEMBER";
workingGroupId: string;
approvalPermissions: ApprovalPermissionValue[];
}; };
type PeriodFormState = { type PeriodFormState = {
@@ -96,9 +108,21 @@ type DashboardMessage = {
text: string; 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 MobileSection = "overview" | "actions";
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs"; type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
const currencyFormatter = new Intl.NumberFormat("de-DE", { const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency", style: "currency",
currency: "EUR" currency: "EUR"
@@ -171,7 +195,8 @@ export function DashboardShell({
managedUsers, managedUsers,
auditLogs, auditLogs,
accountingPeriods, accountingPeriods,
currentPeriodId currentPeriodId,
approvalThreshold
}: DashboardShellProps) { }: DashboardShellProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
@@ -224,7 +249,7 @@ export function DashboardShell({
password: "", password: "",
role: "MEMBER", role: "MEMBER",
workingGroupId: visibleGroups[0]?.id ?? "", workingGroupId: visibleGroups[0]?.id ?? "",
approvalPreference: "" approvalPermissions: []
}); });
const [message, setMessage] = useState<DashboardMessage | null>(null); const [message, setMessage] = useState<DashboardMessage | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -236,9 +261,11 @@ export function DashboardShell({
); );
const [backupFile, setBackupFile] = useState<File | null>(null); 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 [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({}); const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod)); const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
useEffect(() => { useEffect(() => {
if (visibleGroups.length === 0) { if (visibleGroups.length === 0) {
setSelectedMobileGroupId(""); setSelectedMobileGroupId("");
@@ -321,10 +348,6 @@ export function DashboardShell({
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]); }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
useEffect(() => { useEffect(() => {
if (userForm.role !== "MEMBER") {
return;
}
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId); const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
if (!groupStillExists) { if (!groupStillExists) {
@@ -333,8 +356,17 @@ export function DashboardShell({
workingGroupId: visibleGroups[0]?.id ?? "" 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 = 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 ?? [];
@@ -344,6 +376,34 @@ export function DashboardShell({
const selectedPeriodForManagement = const selectedPeriodForManagement =
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; 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<ManagedUserDraft>) {
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(() => { const totals = useMemo(() => {
return visibleGroups.reduce( return visibleGroups.reduce(
(summary, group) => { (summary, group) => {
@@ -693,8 +753,8 @@ export function DashboardShell({
username: createdUsername, username: createdUsername,
password: userForm.password, password: userForm.password,
role: userForm.role, role: userForm.role,
workingGroupId: userForm.role === "MEMBER" ? userForm.workingGroupId : "", workingGroupId: userForm.workingGroupId,
approvalPreference: userForm.role === "ADMIN" ? userForm.approvalPreference : "" approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions)
}) })
}) })
); );
@@ -704,7 +764,7 @@ export function DashboardShell({
password: "", password: "",
role: "MEMBER", role: "MEMBER",
workingGroupId: visibleGroups[0]?.id ?? "", workingGroupId: visibleGroups[0]?.id ?? "",
approvalPreference: "" approvalPermissions: []
}); });
return { return {
@@ -713,10 +773,57 @@ export function DashboardShell({
}; };
}, },
({ createdUsername, createdPassword }) => ({ 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) { async function handleDeleteUser(userId: string) {
await runAction(async () => { await runAction(async () => {
await parseResponse( await parseResponse(
@@ -768,7 +875,7 @@ export function DashboardShell({
if (!backupFile) { if (!backupFile) {
setMessage({ setMessage({
type: "error", type: "error",
text: "Bitte zuerst eine CSV-Datei auswählen." text: "Bitte zuerst eine CSV-Datei auswählen."
}); });
return; return;
} }
@@ -794,7 +901,7 @@ export function DashboardShell({
} }
async function handleRestoreAuditLog(entryId: string, summary: string) { 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; return;
} }
@@ -804,7 +911,7 @@ export function DashboardShell({
method: "POST" method: "POST"
}) })
); );
}, "Änderung wurde zurückgesetzt."); }, "Änderung wurde zurückgesetzt.");
} }
function openPasswordReset(userId: string) { 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 (
<Stack spacing={1}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Freigaberollen
</Typography>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => {
const selected = value.includes(approvalType);
return (
<Button
key={approvalType}
type="button"
size="small"
variant={selected ? "contained" : "outlined"}
onClick={() => onToggle(approvalType)}
>
{approvalLabel(approvalType)}
</Button>
);
})}
</Stack>
<Typography variant="body2" color="text.secondary">
{helperText}
</Typography>
</Stack>
);
}
const islandCardSx = { const islandCardSx = {
borderRadius: { xs: "24px", md: "30px" }, borderRadius: { xs: "24px", md: "30px" },
border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`,
@@ -838,7 +983,7 @@ export function DashboardShell({
Zeitraum wechseln Zeitraum wechseln
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{"Nur Vorstand und Finanz-AG k\u00f6nnen die aktuelle \u00dcbersicht global umstellen."} {"Nur Vorstand und Finanz-AG koennen die aktuelle Uebersicht global umstellen."}
</Typography> </Typography>
</Box> </Box>
<Box <Box
@@ -846,18 +991,19 @@ export function DashboardShell({
display: "grid", display: "grid",
gridTemplateColumns: { gridTemplateColumns: {
xs: "1fr", xs: "1fr",
sm: "minmax(0, 1fr) 148px 184px" md: "minmax(0, 1.45fr) minmax(168px, 0.85fr) minmax(220px, 0.95fr)"
}, },
gap: 1.2, gap: 1.2,
alignItems: "start" alignItems: "stretch"
}} }}
> >
<TextField <TextField
select select
label={"Aktuelle \u00dcbersicht"} label={"Aktuelle Uebersicht"}
value={selectedCurrentPeriodId} value={selectedCurrentPeriodId}
onChange={(event) => setSelectedCurrentPeriodId(event.target.value)} onChange={(event) => setSelectedCurrentPeriodId(event.target.value)}
fullWidth fullWidth
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 0 }} sx={{ minWidth: 0 }}
> >
{accountingPeriods.map((period) => ( {accountingPeriods.map((period) => (
@@ -870,12 +1016,9 @@ export function DashboardShell({
variant="contained" variant="contained"
disabled={busy || selectedCurrentPeriodId === currentPeriodId} disabled={busy || selectedCurrentPeriodId === currentPeriodId}
onClick={handleSetCurrentPeriod} onClick={handleSetCurrentPeriod}
sx={{ sx={{ minWidth: 0, minHeight: 56, px: 2 }}
minWidth: 0,
px: { sm: 1.75 }
}}
> >
{"\u00dcbersicht setzen"} {"Uebersicht setzen"}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -888,7 +1031,7 @@ export function DashboardShell({
return; return;
} }
if (!window.confirm(`Zeitraum "${selectedPeriodForManagement.name}" wirklich l\u00f6schen?`)) { if (!window.confirm(`Zeitraum "${selectedPeriodForManagement.name}" wirklich loeschen?`)) {
return; return;
} }
@@ -896,17 +1039,18 @@ export function DashboardShell({
}} }}
sx={{ sx={{
minWidth: 0, minWidth: 0,
px: { sm: 2.25 }, minHeight: 56,
whiteSpace: "nowrap" px: 2.5,
whiteSpace: "normal"
}} }}
> >
{"Zeitraum l\u00f6schen"} {"Zeitraum loeschen"}
</Button> </Button>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{selectedPeriodForManagement?.isCurrent {selectedPeriodForManagement?.isCurrent
? "Der aktuell aktive Zeitraum kann nicht gel\u00f6scht werden." ? "Der aktuell aktive Zeitraum kann nicht geloescht werden."
: "Leere, nicht aktive Zeitr\u00e4ume lassen sich hier wieder entfernen."} : "Leere, nicht aktive Zeitraeume lassen sich hier wieder entfernen."}
</Typography> </Typography>
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}> <Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
@@ -943,7 +1087,7 @@ export function DashboardShell({
</Stack> </Stack>
<TextField <TextField
select select
label={"Budgets \u00fcbernehmen"} label={"Budgets uebernehmen"}
value={periodForm.copyBudgetsFromPeriodId} value={periodForm.copyBudgetsFromPeriodId}
onChange={(event) => onChange={(event) =>
setPeriodForm((current) => ({ setPeriodForm((current) => ({
@@ -952,9 +1096,9 @@ export function DashboardShell({
})) }))
} }
fullWidth fullWidth
helperText={"Optional kopiert die vorhandenen Budgett\u00f6pfe direkt in den neuen Zeitraum."} helperText={"Optional kopiert die vorhandenen Budgettoepfe direkt in den neuen Zeitraum."}
> >
<MenuItem value="">{`Ohne Budget\u00fcbernahme`}</MenuItem> <MenuItem value="">Ohne Budgetuebernahme</MenuItem>
{accountingPeriods.map((period) => ( {accountingPeriods.map((period) => (
<MenuItem key={period.id} value={period.id}> <MenuItem key={period.id} value={period.id}>
{period.name} {period.name}
@@ -968,7 +1112,6 @@ export function DashboardShell({
</Box> </Box>
</Stack> </Stack>
) : null; ) : null;
const actionCards = ( const actionCards = (
<Stack <Stack
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3} spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
@@ -999,7 +1142,7 @@ export function DashboardShell({
</Typography> </Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
{"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "} {"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "}
{AUTO_APPROVAL_THRESHOLD} {approvalThreshold.toFixed(2)}
{" EUR wird automatisch freigegeben."} {" EUR wird automatisch freigegeben."}
</Typography> </Typography>
</Box> </Box>
@@ -1238,7 +1381,7 @@ export function DashboardShell({
CSV-Backup CSV-Backup
</Typography> </Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
{"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."}
</Typography> </Typography>
</Box> </Box>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} useFlexGap flexWrap="wrap"> <Stack direction={{ xs: "column", sm: "row" }} gap={1.2} useFlexGap flexWrap="wrap">
@@ -1251,7 +1394,7 @@ export function DashboardShell({
CSV herunterladen CSV herunterladen
</Button> </Button>
<Button component="label" variant="outlined" disabled={busy}> <Button component="label" variant="outlined" disabled={busy}>
CSV auswählen CSV auswählen
<input <input
hidden hidden
type="file" type="file"
@@ -1270,8 +1413,8 @@ export function DashboardShell({
</Stack> </Stack>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{backupFile {backupFile
? `Ausgewählt: ${backupFile.name}` ? `Ausgewählt: ${backupFile.name}`
: "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."} : "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."}
</Typography> </Typography>
</Stack> </Stack>
</CardContent> </CardContent>
@@ -1279,116 +1422,141 @@ export function DashboardShell({
) : null} ) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( {canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
<Card sx={islandCardSx}> <Stack spacing={3}>
<CardContent sx={{ p: 3 }}> <Card sx={islandCardSx}>
<Stack spacing={2.5}> <CardContent sx={{ p: 3 }}>
<Box> <Stack spacing={2.5}>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}> <Box>
Nutzer anlegen <Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
</Typography> Nutzer anlegen
<Typography color="text.secondary"> </Typography>
{"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."} <Typography color="text.secondary">
</Typography> {"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."}
</Box> </Typography>
<Box component="form" onSubmit={handleCreateUser}> </Box>
<Stack spacing={2}> <Box component="form" onSubmit={handleCreateUser}>
<TextField <Stack spacing={2}>
label="Login-Name"
helperText={"Damit melden sich Nutzer sp\u00e4ter an und so werden sie auch angezeigt."}
value={userForm.username}
onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
required
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
<TextField <TextField
label="Startpasswort" label="Login-Name"
value={userForm.password} helperText={"Damit melden sich Nutzer spaeter an und so werden sie auch angezeigt."}
onChange={(event) => value={userForm.username}
setUserForm((current) => ({ ...current, password: event.target.value })) onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
}
required required
fullWidth
helperText={"Dieses Passwort wird nach dem Anlegen oben als Best\u00e4tigung angezeigt."}
/> />
<Button <Stack direction={{ xs: "column", sm: "row" }} gap={1}>
type="button" <TextField
variant="outlined" label="Startpasswort"
onClick={() => value={userForm.password}
setUserForm((current) => ({ onChange={(event) =>
...current, setUserForm((current) => ({ ...current, password: event.target.value }))
password: generatePassword() }
})) required
} fullWidth
sx={{ minWidth: { sm: 148 } }} helperText={"Dieses Passwort wird nach dem Anlegen oben als Bestaetigung angezeigt."}
> />
Generieren <Button
</Button> type="button"
</Stack> variant="outlined"
<TextField onClick={() =>
select setUserForm((current) => ({
label="Rolle" ...current,
value={userForm.role} password: generatePassword()
onChange={(event) => }))
setUserForm((current) => ({ }
...current, sx={{ minWidth: { sm: 148 } }}
role: event.target.value as UserFormState["role"], >
approvalPreference: event.target.value === "ADMIN" ? current.approvalPreference : "", Generieren
workingGroupId: event.target.value === "MEMBER" ? current.workingGroupId : "" </Button>
})) </Stack>
}
required
>
<MenuItem value="ADMIN">Vorstand</MenuItem>
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
</TextField>
{userForm.role === "MEMBER" ? (
<TextField <TextField
select select
label="Arbeitsgruppe" label="Rolle"
value={userForm.role}
onChange={(event) => {
const nextRole = event.target.value as UserFormState["role"];
setUserForm((current) => ({
...current,
role: nextRole,
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
}));
}}
required
>
<MenuItem value="ADMIN">Vorstand</MenuItem>
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
</TextField>
<TextField
select
label="AG-Zuordnung"
value={userForm.workingGroupId} value={userForm.workingGroupId}
onChange={(event) => onChange={(event) =>
setUserForm((current) => ({ ...current, workingGroupId: event.target.value })) 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" ? <MenuItem value="">Ohne AG</MenuItem> : null}
{visibleGroups.map((group) => ( {visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}> <MenuItem key={group.id} value={group.id}>
{group.name} {group.name}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
) : null} {renderApprovalPermissionSelector(
userForm.approvalPermissions,
{userForm.role === "ADMIN" ? ( (approvalType) =>
<TextField
select
label={"Prim\u00e4re Freigaberolle"}
value={userForm.approvalPreference}
onChange={(event) =>
setUserForm((current) => ({ setUserForm((current) => ({
...current, ...current,
approvalPreference: event.target.value as UserFormState["approvalPreference"] approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType)
})) })),
} "Lege fest, fuer welche Freigabeschritte dieses Konto zeichnen darf."
> )}
<MenuItem value="">Keine Voreinstellung</MenuItem> <Button type="submit" variant="outlined" disabled={busy}>
<MenuItem value="CHAIR_A">Vorstand A</MenuItem> Nutzer speichern
<MenuItem value="CHAIR_B">Vorstand B</MenuItem> </Button>
</TextField> </Stack>
) : null} </Box>
</Stack>
</CardContent>
</Card>
<Button type="submit" variant="outlined" disabled={busy}> <Card sx={islandCardSx}>
Nutzer speichern <CardContent sx={{ p: 3 }}>
</Button> <Stack spacing={2}>
</Stack> <Box>
</Box> <Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
</Stack> Freigabe-Schwelle
</CardContent> </Typography>
</Card> <Typography color="text.secondary">
{"Ausgaben unter diesem Betrag werden automatisch freigegeben."}
</Typography>
</Box>
<TextField
label="Schwelle in EUR"
type="number"
inputProps={{ min: 0, step: 0.01 }}
value={approvalThresholdDraft}
onChange={(event) => setApprovalThresholdDraft(event.target.value)}
helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`}
fullWidth
/>
<Button type="button" variant="outlined" disabled={busy} onClick={handleSaveApprovalThreshold}>
Schwelle speichern
</Button>
</Stack>
</CardContent>
</Card>
</Stack>
) : null} ) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( {canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
<Card sx={islandCardSx}> <Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>
@@ -1398,13 +1566,15 @@ export function DashboardShell({
Nutzer verwalten Nutzer verwalten
</Typography> </Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
{"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."}
</Typography> </Typography>
</Box> </Box>
<Stack spacing={1.4}> <Stack spacing={1.4}>
{managedUsers.map((user) => { {managedUsers.map((user) => {
const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0; const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0;
const isResetOpen = editingPasswordUserId === user.id; const isResetOpen = editingPasswordUserId === user.id;
const isEditingUser = editingUserId === user.id;
const draft = getManagedUserDraft(user);
return ( return (
<Box key={user.id} sx={nestedPanelSx}> <Box key={user.id} sx={nestedPanelSx}>
@@ -1422,6 +1592,22 @@ export function DashboardShell({
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap"> <Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button
size="small"
variant={isEditingUser ? "contained" : "outlined"}
startIcon={<EditRoundedIcon />}
disabled={busy}
onClick={() => {
if (isEditingUser) {
setEditingUserId(null);
return;
}
openUserEditor(user);
}}
>
Bearbeiten
</Button>
<Button <Button
size="small" size="small"
variant={isResetOpen ? "contained" : "outlined"} variant={isResetOpen ? "contained" : "outlined"}
@@ -1445,14 +1631,14 @@ export function DashboardShell({
startIcon={<DeleteOutlineRoundedIcon />} startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy || !canDelete} disabled={busy || !canDelete}
onClick={async () => { onClick={async () => {
if (!window.confirm(`Nutzer "${user.username}" wirklich l\u00f6schen?`)) { if (!window.confirm(`Nutzer "${user.username}" wirklich loeschen?`)) {
return; return;
} }
await handleDeleteUser(user.id); await handleDeleteUser(user.id);
}} }}
> >
{"L\u00f6schen"} {"Loeschen"}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
@@ -1460,8 +1646,93 @@ export function DashboardShell({
<Chip label={user.workingGroupName ?? "Ohne AG"} size="small" variant="outlined" /> <Chip label={user.workingGroupName ?? "Ohne AG"} size="small" variant="outlined" />
<Chip label={`Ausgaben: ${user.createdExpensesCount}`} size="small" variant="outlined" /> <Chip label={`Ausgaben: ${user.createdExpensesCount}`} size="small" variant="outlined" />
<Chip label={`Freigaben: ${user.approvalsCount}`} size="small" variant="outlined" /> <Chip label={`Freigaben: ${user.approvalsCount}`} size="small" variant="outlined" />
{user.approvalPermissions.length > 0 ? (
user.approvalPermissions.map((approvalType) => (
<Chip key={approvalType} label={approvalLabel(approvalType)} size="small" color="primary" variant="outlined" />
))
) : (
<Chip label="Keine Freigaberolle" size="small" variant="outlined" />
)}
</Stack> </Stack>
{isEditingUser ? (
<Box
sx={{
...nestedPanelSx,
p: 1.5,
borderColor: alpha(theme.palette.primary.main, 0.18),
backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06)
}}
>
<Stack spacing={1.4}>
<TextField
select
label="Rolle"
value={draft.role}
onChange={(event) => {
const nextRole = event.target.value as ManagedUserDraft["role"];
updateManagedUserDraft(user, {
role: nextRole,
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
});
}}
fullWidth
>
<MenuItem value="ADMIN">Vorstand</MenuItem>
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
</TextField>
<TextField
select
label="AG-Zuordnung"
value={draft.workingGroupId}
onChange={(event) => 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" ? <MenuItem value="">Ohne AG</MenuItem> : null}
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
{renderApprovalPermissionSelector(
draft.approvalPermissions,
(approvalType) =>
updateManagedUserDraft(user, {
approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType)
}),
"Lege fest, welche Freigabeschritte dieses Konto autorisieren darf."
)}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button type="button" variant="contained" disabled={busy} onClick={() => handleUpdateUser(user)}>
Nutzer speichern
</Button>
<Button
type="button"
variant="text"
disabled={busy}
onClick={() => {
resetManagedUserDraft(user);
setEditingUserId(null);
}}
>
Abbrechen
</Button>
</Stack>
</Stack>
</Box>
) : null}
{isResetOpen ? ( {isResetOpen ? (
<Box <Box
sx={{ sx={{
@@ -1483,7 +1754,7 @@ export function DashboardShell({
} }
fullWidth fullWidth
helperText={ helperText={
"Nur neu gesetzte Passw\u00f6rter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen." "Nur neu gesetzte Passwoerter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen."
} }
/> />
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap"> <Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
@@ -1529,13 +1800,6 @@ export function DashboardShell({
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
{canManagePeriods && isCompactLayout ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? (
<Card sx={islandCardSx}> <Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>
@@ -1585,7 +1849,7 @@ export function DashboardShell({
onClick={() => handleRestoreAuditLog(entry.id, entry.summary)} onClick={() => handleRestoreAuditLog(entry.id, entry.summary)}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Zustand zurücksetzen Zustand zurücksetzen
</Button> </Button>
) : null} ) : null}
</Stack> </Stack>
@@ -1687,14 +1951,14 @@ export function DashboardShell({
group={group} group={group}
viewer={viewer} viewer={viewer}
busy={busy} busy={busy}
approvalThreshold={approvalThreshold}
onApprove={handleApprove} onApprove={handleApprove}
onMarkPaid={handleMarkPaid} onMarkPaid={handleMarkPaid}
onDocument={handleDocument} onDocument={handleDocument}
onSaveWorkingGroup={handleSaveWorkingGroup} onSaveWorkingGroup={handleSaveWorkingGroup}
onDeleteWorkingGroup={handleDeleteWorkingGroup} onDeleteWorkingGroup={handleDeleteWorkingGroup}
onSaveBudget={handleSaveBudget} onSaveBudget={handleSaveBudget}
onDeleteBudget={handleDeleteBudget} onDeleteBudget={handleDeleteBudget} onDeleteExpense={handleDeleteExpense}
onDeleteExpense={handleDeleteExpense}
/> />
))} ))}
</Stack> </Stack>
@@ -1710,7 +1974,7 @@ export function DashboardShell({
) : desktopSection === "periods" ? ( ) : desktopSection === "periods" ? (
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start"> <Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
{canManagePeriods ? ( {canManagePeriods ? (
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 430 }, flexShrink: 0 }}> <Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 560 }, flexShrink: 0 }}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent> <CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card> </Card>
) : null} ) : null}

24
src/lib/app-settings.ts Normal file
View File

@@ -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;
}

View File

@@ -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<WorkingGroup, "id" | "name" | "createdAt">) { export function snapshotWorkingGroup(workingGroup: Pick<WorkingGroup, "id" | "name" | "createdAt">) {
return { return {
@@ -19,6 +19,14 @@ export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "s
}; };
} }
export function snapshotAppSettings(settings: Pick<AppSettings, "id" | "approvalThreshold" | "createdAt">) {
return {
id: settings.id,
approvalThreshold: Number(settings.approvalThreshold),
createdAt: settings.createdAt.toISOString()
};
}
export function snapshotBudget(budget: Pick<Budget, "id" | "name" | "totalBudget" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">) { export function snapshotBudget(budget: Pick<Budget, "id" | "name" | "totalBudget" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">) {
return { return {
id: budget.id, id: budget.id,
@@ -81,7 +89,16 @@ export function snapshotApproval(approval: Pick<Approval, "id" | "expenseId" | "
export function snapshotUser( export function snapshotUser(
user: Pick< user: Pick<
User, User,
"id" | "name" | "username" | "email" | "passwordHash" | "role" | "approvalPreference" | "workingGroupId" | "createdAt" | "id"
| "name"
| "username"
| "email"
| "passwordHash"
| "role"
| "approvalPreference"
| "approvalPermissions"
| "workingGroupId"
| "createdAt"
> >
) { ) {
return { return {
@@ -92,6 +109,7 @@ export function snapshotUser(
passwordHash: user.passwordHash, passwordHash: user.passwordHash,
role: user.role, role: user.role,
approvalPreference: user.approvalPreference, approvalPreference: user.approvalPreference,
approvalPermissions: user.approvalPermissions,
workingGroupId: user.workingGroupId, workingGroupId: user.workingGroupId,
createdAt: user.createdAt.toISOString() createdAt: user.createdAt.toISOString()
}; };

View File

@@ -3,6 +3,7 @@ import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { normalizeApprovalPermissions } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
const credentialsSchema = z.object({ const credentialsSchema = z.object({
@@ -55,7 +56,11 @@ export const authOptions: NextAuthOptions = {
email: matchedUser.email, email: matchedUser.email,
role: matchedUser.role, role: matchedUser.role,
workingGroupId: matchedUser.workingGroupId, 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.username = user.username;
token.role = user.role; token.role = user.role;
token.workingGroupId = user.workingGroupId; token.workingGroupId = user.workingGroupId;
token.approvalPreference = user.approvalPreference; token.approvalPermissions = user.approvalPermissions;
} }
return token; return token;
@@ -78,7 +83,7 @@ export const authOptions: NextAuthOptions = {
session.user.username = token.username ?? ""; session.user.username = token.username ?? "";
session.user.role = token.role ?? "MEMBER"; session.user.role = token.role ?? "MEMBER";
session.user.workingGroupId = token.workingGroupId ?? null; session.user.workingGroupId = token.workingGroupId ?? null;
session.user.approvalPreference = token.approvalPreference ?? null; session.user.approvalPermissions = token.approvalPermissions ?? [];
} }
return session; return session;

View File

@@ -14,7 +14,7 @@ export type DashboardViewer = {
username: string; username: string;
role: AppRole; role: AppRole;
workingGroupId: string | null; workingGroupId: string | null;
approvalPreference: ApprovalTypeValue | null; approvalPermissions: ApprovalTypeValue[];
}; };
export type DashboardApproval = { export type DashboardApproval = {
@@ -76,7 +76,7 @@ export type DashboardManagedUser = {
role: AppRole; role: AppRole;
workingGroupId: string | null; workingGroupId: string | null;
workingGroupName: string | null; workingGroupName: string | null;
approvalPreference: ApprovalTypeValue | null; approvalPermissions: ApprovalTypeValue[];
createdExpensesCount: number; createdExpensesCount: number;
approvalsCount: number; approvalsCount: number;
}; };

View File

@@ -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 APPROVAL_FLOW = ["CHAIR_A", "CHAIR_B", "FINANCE"] as const;
export const COLOR_PRESETS = [ export const COLOR_PRESETS = [
@@ -19,8 +19,8 @@ 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 function requiresManualApproval(amount: number) { export function requiresManualApproval(amount: number, approvalThreshold = DEFAULT_APPROVAL_THRESHOLD) {
return amount >= AUTO_APPROVAL_THRESHOLD; return amount >= approvalThreshold;
} }
export function roleLabel(role: AppRole) { export function roleLabel(role: AppRole) {
@@ -97,26 +97,42 @@ export function canDeleteExpense(
return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt; 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, 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[] existingApprovals: ApprovalTypeValue[]
): ApprovalTypeValue[] { ): ApprovalTypeValue[] {
const missingApprovals = APPROVAL_FLOW.filter( return APPROVAL_FLOW.filter(
(approvalType) => !existingApprovals.includes(approvalType) (approvalType) => approvalPermissions.includes(approvalType) && !existingApprovals.includes(approvalType)
) as ApprovalTypeValue[]; ) 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 [];
} }

View File

@@ -8,7 +8,7 @@ declare module "next-auth" {
username: string; username: string;
role: Role; role: Role;
workingGroupId: string | null; workingGroupId: string | null;
approvalPreference: ApprovalType | null; approvalPermissions: ApprovalType[];
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
@@ -17,7 +17,7 @@ declare module "next-auth" {
username: string; username: string;
role: Role; role: Role;
workingGroupId: string | null; workingGroupId: string | null;
approvalPreference: ApprovalType | null; approvalPermissions: ApprovalType[];
} }
} }
@@ -27,6 +27,6 @@ declare module "next-auth/jwt" {
username?: string; username?: string;
role?: Role; role?: Role;
workingGroupId?: string | null; workingGroupId?: string | null;
approvalPreference?: ApprovalType | null; approvalPermissions?: ApprovalType[];
} }
} }