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

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">) {
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">) {
return {
id: budget.id,
@@ -81,7 +89,16 @@ export function snapshotApproval(approval: Pick<Approval, "id" | "expenseId" | "
export function snapshotUser(
user: Pick<
User,
"id" | "name" | "username" | "email" | "passwordHash" | "role" | "approvalPreference" | "workingGroupId" | "createdAt"
| "id"
| "name"
| "username"
| "email"
| "passwordHash"
| "role"
| "approvalPreference"
| "approvalPermissions"
| "workingGroupId"
| "createdAt"
>
) {
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()
};

View File

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

View File

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

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 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 [];
}