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
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:
24
src/lib/app-settings.ts
Normal file
24
src/lib/app-settings.ts
Normal 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;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user