commit f9b17e996482e0ceb10940706af4e56877480929 Author: Jan Date: Wed Apr 8 16:30:44 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..37ee38f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +npm-debug.log + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..997a2b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +POSTGRES_DB="rave_budget_control" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="change-this-db-password" +DATABASE_URL="postgresql://postgres:postgres@db:5432/rave_budget_control?schema=public" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="replace-this-with-a-long-random-secret" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..582b5dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +.next +dist +coverage +.env +.env.local +uploads +public/uploads +prisma/dev.db + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..69a71cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-bookworm-slim + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* + +COPY package.json ./ +RUN npm install + +COPY . . + +RUN npx prisma generate +RUN npm run build + +EXPOSE 3000 + +CMD ["sh", "./docker/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..255592e --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# RFP Finanzübersicht + +Material-3-orientierter MVP fuer die Budgetsteuerung von Vereins-AGs mit rollenbasierter Freigabelogik, PWA-Grundlage und Docker-Setup. + +## Stack + +- Next.js 14 mit App Router und TypeScript +- MUI 6 fuer Material Design 3 +- Prisma + PostgreSQL +- NextAuth Credentials Login +- Docker Compose fuer lokalen Start + +## Enthalten im MVP + +- Horizontale Budget-Dashboard-Ansicht pro AG +- Budget-Fuellstand mit blasser/gefaerbter Visualisierung +- Automatische Freigabe fuer Ausgaben unter 50 EUR +- Drei digitale Freigaben fuer Ausgaben ab 50 EUR +- Rollen `Admin`, `Finanz-AG`, `AG-Mitglied` +- Admin-Formulare fuer Budgets und Nutzeranlage +- Statusaktionen `Freigegeben`, `Bezahlt`, `Dokumentiert` +- Demo-Accounts und Seed-Daten +- PWA-Manifest und Service-Worker-Basis fuer Offline-Shell + +## Lokaler Start + +1. Optional `.env.example` nach `.env` kopieren und Werte anpassen. +2. Projekt starten: + +```bash +docker-compose up --build +``` + +3. App oeffnen unter `http://localhost:3000` + +## Demo-Accounts + +- `admin.a@raveforpeace.org` / `demo123!` +- `admin.b@raveforpeace.org` / `demo123!` +- `finance@raveforpeace.org` / `demo123!` +- `deko@raveforpeace.org` / `demo123!` +- `technik@raveforpeace.org` / `demo123!` + +## Abnahmekriterien durchspielen + +1. Als `admin.a@raveforpeace.org` anmelden und bei `AG Deko` ein Budget setzen, zum Beispiel `1200`. +2. Als `deko@raveforpeace.org` anmelden und eine Ausgabe ueber `60` EUR erfassen. +3. Als `admin.a@raveforpeace.org` wieder anmelden und `Freigeben als Vorstand A` klicken. +4. Als `admin.b@raveforpeace.org` anmelden und `Freigeben als Vorstand B` klicken. +5. Als `finance@raveforpeace.org` anmelden und die Finanzfreigabe setzen. +6. Nach der dritten Freigabe wird der Posten kraeftig dargestellt. +7. Danach `Bezahlt setzen` klicken, um den blauen Haken zu erhalten. + +## Datenmodell + +- `users`: Nutzer mit Rolle, Login und optionaler AG-Zuordnung +- `working_groups`: AG-Name, Budget, Farbe +- `expenses`: Betrag, Status, Bezahlt-/Dokumentiert-Zeitstempel, optionaler Beleg-Link +- `approvals`: Wer hat welche Signatur fuer welchen Posten gesetzt + +## Hinweise + +- Die Dokumentation eines Belegs ist im MVP als Beleg-URL umgesetzt. +- Web-Push ist architektonisch vorbereitet ueber PWA-Grundlage, aber die eigentliche Push-Auslieferung ist noch nicht implementiert. +- Fuer Produktion sollten `NEXTAUTH_SECRET`, Datenbank-Zugangsdaten und Reverse-Proxy/SSL sauber gesetzt werden. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27f5b38 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-rave_budget_control} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d rave_budget_control"] + interval: 10s + timeout: 5s + retries: 10 + + app: + build: + context: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-rave_budget_control}?schema=public + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + ports: + - "3000:3000" + +volumes: + postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..bbc64c9 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +mkdir -p public/uploads + +echo "Applying database migrations..." +npx prisma migrate deploy + +echo "Seeding demo data..." +npx prisma db seed + +echo "Starting Next.js..." +exec npm run start + diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..adeaa6c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// This file is auto-generated by Next.js. + diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..944d9fe --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "rave-budget-control", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy", + "seed": "tsx prisma/seed.ts" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.3", + "@mui/material": "^6.1.3", + "@prisma/client": "^5.20.0", + "bcryptjs": "^2.4.3", + "next": "^14.2.14", + "next-auth": "^4.24.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.7.4", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "prisma": "^5.20.0", + "tsx": "^4.19.1", + "typescript": "^5.6.2" + } +} diff --git a/prisma/migrations/202604070001_init/migration.sql b/prisma/migrations/202604070001_init/migration.sql new file mode 100644 index 0000000..6207574 --- /dev/null +++ b/prisma/migrations/202604070001_init/migration.sql @@ -0,0 +1,81 @@ +CREATE TYPE "Role" AS ENUM ('ADMIN', 'FINANCE', 'MEMBER'); +CREATE TYPE "ApprovalType" AS ENUM ('CHAIR_A', 'CHAIR_B', 'FINANCE'); +CREATE TYPE "ApprovalStatus" AS ENUM ('PENDING', 'APPROVED'); + +CREATE TABLE "working_groups" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "total_budget" DECIMAL(10,2) NOT NULL, + "color_code" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "working_groups_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "role" "Role" NOT NULL, + "approval_preference" "ApprovalType", + "working_group_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "expenses" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "creator_id" TEXT NOT NULL, + "ag_id" TEXT NOT NULL, + "approval_status" "ApprovalStatus" NOT NULL DEFAULT 'PENDING', + "paid_at" TIMESTAMP(3), + "documented_at" TIMESTAMP(3), + "proof_url" TEXT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "expenses_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "approvals" ( + "id" TEXT NOT NULL, + "expense_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "approval_type" "ApprovalType" NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "approvals_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "working_groups_name_key" ON "working_groups"("name"); +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); +CREATE UNIQUE INDEX "approvals_expense_id_approval_type_key" ON "approvals"("expense_id", "approval_type"); + +ALTER TABLE "users" + ADD CONSTRAINT "users_working_group_id_fkey" + FOREIGN KEY ("working_group_id") REFERENCES "working_groups"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "expenses" + ADD CONSTRAINT "expenses_creator_id_fkey" + FOREIGN KEY ("creator_id") REFERENCES "users"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "expenses" + ADD CONSTRAINT "expenses_ag_id_fkey" + FOREIGN KEY ("ag_id") REFERENCES "working_groups"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "approvals" + ADD CONSTRAINT "approvals_expense_id_fkey" + FOREIGN KEY ("expense_id") REFERENCES "expenses"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "approvals" + ADD CONSTRAINT "approvals_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/202604071700_budget_accounts/migration.sql b/prisma/migrations/202604071700_budget_accounts/migration.sql new file mode 100644 index 0000000..de36bd9 --- /dev/null +++ b/prisma/migrations/202604071700_budget_accounts/migration.sql @@ -0,0 +1,84 @@ +ALTER TABLE "users" ADD COLUMN "username" TEXT; + +WITH ranked AS ( + SELECT + id, + CASE + WHEN lower(regexp_replace(split_part(email, '@', 1), '[^a-zA-Z0-9_-]', '-', 'g')) = '' + THEN CONCAT('user-', substring(id from 1 for 6)) + ELSE lower(regexp_replace(split_part(email, '@', 1), '[^a-zA-Z0-9_-]', '-', 'g')) + END AS base, + ROW_NUMBER() OVER ( + PARTITION BY CASE + WHEN lower(regexp_replace(split_part(email, '@', 1), '[^a-zA-Z0-9_-]', '-', 'g')) = '' + THEN CONCAT('user-', substring(id from 1 for 6)) + ELSE lower(regexp_replace(split_part(email, '@', 1), '[^a-zA-Z0-9_-]', '-', 'g')) + END + ORDER BY created_at, id + ) AS rn + FROM "users" +) +UPDATE "users" AS u +SET "username" = CASE + WHEN ranked.rn = 1 THEN ranked.base + ELSE CONCAT(ranked.base, '-', ranked.rn) +END +FROM ranked +WHERE u.id = ranked.id; + +ALTER TABLE "users" ALTER COLUMN "username" SET NOT NULL; +ALTER TABLE "users" ALTER COLUMN "email" DROP NOT NULL; +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +CREATE TABLE "budgets" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "total_budget" DECIMAL(10,2) NOT NULL, + "color_code" TEXT NOT NULL, + "working_group_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "budgets_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "budgets" ( + "id", + "name", + "total_budget", + "color_code", + "working_group_id", + "created_at", + "updated_at" +) +SELECT + CONCAT('budget_', "id"), + 'Hauptbudget', + "total_budget", + "color_code", + "id", + "created_at", + "updated_at" +FROM "working_groups"; + +CREATE UNIQUE INDEX "budgets_working_group_id_name_key" ON "budgets"("working_group_id", "name"); + +ALTER TABLE "budgets" + ADD CONSTRAINT "budgets_working_group_id_fkey" + FOREIGN KEY ("working_group_id") REFERENCES "working_groups"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "expenses" RENAME COLUMN "notes" TO "description"; +ALTER TABLE "expenses" ADD COLUMN "budget_id" TEXT; + +UPDATE "expenses" +SET "budget_id" = CONCAT('budget_', "ag_id"); + +ALTER TABLE "expenses" ALTER COLUMN "budget_id" SET NOT NULL; + +ALTER TABLE "expenses" + ADD CONSTRAINT "expenses_budget_id_fkey" + FOREIGN KEY ("budget_id") REFERENCES "budgets"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "working_groups" DROP COLUMN "total_budget"; +ALTER TABLE "working_groups" DROP COLUMN "color_code"; diff --git a/prisma/migrations/202604081200_accounting_periods/migration.sql b/prisma/migrations/202604081200_accounting_periods/migration.sql new file mode 100644 index 0000000..a1d6b68 --- /dev/null +++ b/prisma/migrations/202604081200_accounting_periods/migration.sql @@ -0,0 +1,60 @@ +CREATE TYPE "ExpenseRecurrence" AS ENUM ('NONE', 'MONTHLY'); + +CREATE TABLE "accounting_periods" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "starts_at" TIMESTAMP(3) NOT NULL, + "ends_at" TIMESTAMP(3) NOT NULL, + "is_current" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "accounting_periods_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "accounting_periods_name_key" ON "accounting_periods"("name"); +CREATE UNIQUE INDEX "accounting_periods_is_current_key" ON "accounting_periods"("is_current") WHERE "is_current" = true; + +INSERT INTO "accounting_periods" ( + "id", + "name", + "starts_at", + "ends_at", + "is_current", + "created_at", + "updated_at" +) +VALUES ( + 'period_current', + CONCAT('Haushalt ', EXTRACT(YEAR FROM CURRENT_DATE)::text), + date_trunc('year', CURRENT_DATE)::timestamp(3), + (date_trunc('year', CURRENT_DATE) + interval '1 year' - interval '1 day')::timestamp(3), + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +ALTER TABLE "budgets" ADD COLUMN "period_id" TEXT; +ALTER TABLE "expenses" ADD COLUMN "period_id" TEXT; +ALTER TABLE "expenses" ADD COLUMN "recurrence" "ExpenseRecurrence" NOT NULL DEFAULT 'NONE'; + +UPDATE "budgets" +SET "period_id" = 'period_current'; + +UPDATE "expenses" +SET "period_id" = 'period_current'; + +ALTER TABLE "budgets" ALTER COLUMN "period_id" SET NOT NULL; +ALTER TABLE "expenses" ALTER COLUMN "period_id" SET NOT NULL; + +DROP INDEX "budgets_working_group_id_name_key"; +CREATE UNIQUE INDEX "budgets_working_group_id_period_id_name_key" ON "budgets"("working_group_id", "period_id", "name"); + +ALTER TABLE "budgets" + ADD CONSTRAINT "budgets_period_id_fkey" + FOREIGN KEY ("period_id") REFERENCES "accounting_periods"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "expenses" + ADD CONSTRAINT "expenses_period_id_fkey" + FOREIGN KEY ("period_id") REFERENCES "accounting_periods"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/202604081930_audit_logs/migration.sql b/prisma/migrations/202604081930_audit_logs/migration.sql new file mode 100644 index 0000000..88b116a --- /dev/null +++ b/prisma/migrations/202604081930_audit_logs/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "actor_id" TEXT, + "action" TEXT NOT NULL, + "entity_type" TEXT NOT NULL, + "entity_id" TEXT, + "entity_label" TEXT, + "summary" TEXT NOT NULL, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at"); + +ALTER TABLE "audit_logs" +ADD CONSTRAINT "audit_logs_actor_id_fkey" +FOREIGN KEY ("actor_id") REFERENCES "users"("id") +ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..472e7c6 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,146 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + ADMIN + FINANCE + MEMBER +} + +enum ApprovalType { + CHAIR_A + CHAIR_B + FINANCE +} + +enum ApprovalStatus { + PENDING + APPROVED +} + +enum ExpenseRecurrence { + NONE + MONTHLY +} + +model User { + id String @id @default(cuid()) + name String + username String @unique + email String? @unique + passwordHash String @map("password_hash") + role Role + approvalPreference ApprovalType? @map("approval_preference") + workingGroupId String? @map("working_group_id") + workingGroup WorkingGroup? @relation(fields: [workingGroupId], references: [id], onDelete: SetNull) + createdExpenses Expense[] @relation("ExpenseCreator") + approvals Approval[] + auditLogs AuditLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("users") +} + +model AccountingPeriod { + id String @id @default(cuid()) + name String @unique + startsAt DateTime @map("starts_at") + endsAt DateTime @map("ends_at") + isCurrent Boolean @default(false) @map("is_current") + budgets Budget[] + expenses Expense[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("accounting_periods") +} + +model WorkingGroup { + id String @id @default(cuid()) + name String @unique + members User[] + budgets Budget[] + expenses Expense[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("working_groups") +} + +model Budget { + id String @id @default(cuid()) + name String + totalBudget Decimal @db.Decimal(10, 2) @map("total_budget") + colorCode String @map("color_code") + workingGroupId String @map("working_group_id") + periodId String @map("period_id") + workingGroup WorkingGroup @relation(fields: [workingGroupId], references: [id], onDelete: Cascade) + period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) + expenses Expense[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([workingGroupId, periodId, name]) + @@map("budgets") +} + +model Expense { + id String @id @default(cuid()) + title String + description String? + amount Decimal @db.Decimal(10, 2) + creatorId String @map("creator_id") + agId String @map("ag_id") + budgetId String @map("budget_id") + periodId String @map("period_id") + approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") + recurrence ExpenseRecurrence @default(NONE) + paidAt DateTime? @map("paid_at") + documentedAt DateTime? @map("documented_at") + proofUrl String? @map("proof_url") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + creator User @relation("ExpenseCreator", fields: [creatorId], references: [id], onDelete: Restrict) + workingGroup WorkingGroup @relation(fields: [agId], references: [id], onDelete: Cascade) + budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) + period AccountingPeriod @relation(fields: [periodId], references: [id], onDelete: Restrict) + approvals Approval[] + + @@map("expenses") +} + +model Approval { + id String @id @default(cuid()) + expenseId String @map("expense_id") + userId String @map("user_id") + approvalType ApprovalType @map("approval_type") + timestamp DateTime @default(now()) + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([expenseId, approvalType]) + @@map("approvals") +} + +model AuditLog { + id String @id @default(cuid()) + actorId String? @map("actor_id") + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + action String + entityType String @map("entity_type") + entityId String? @map("entity_id") + entityLabel String? @map("entity_label") + summary String + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + @@index([createdAt]) + @@map("audit_logs") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..b651b62 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,250 @@ +import { ApprovalStatus, ApprovalType, ExpenseRecurrence, PrismaClient, Role } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +async function upsertCurrentPeriod() { + const year = new Date().getFullYear(); + const startsAt = new Date(Date.UTC(year, 0, 1)); + const endsAt = new Date(Date.UTC(year, 11, 31, 23, 59, 59)); + + await prisma.accountingPeriod.updateMany({ + data: { + isCurrent: false + } + }); + + return prisma.accountingPeriod.upsert({ + where: { + name: `Haushalt ${year}` + }, + update: { + startsAt, + endsAt, + isCurrent: true + }, + create: { + name: `Haushalt ${year}`, + startsAt, + endsAt, + isCurrent: true + } + }); +} + +async function upsertWorkingGroup(name: string) { + return prisma.workingGroup.upsert({ + where: { name }, + update: {}, + create: { + name + } + }); +} + +async function upsertBudget( + workingGroupId: string, + periodId: string, + name: string, + totalBudget: number, + colorCode: string +) { + return prisma.budget.upsert({ + where: { + workingGroupId_periodId_name: { + workingGroupId, + periodId, + name + } + }, + update: { + totalBudget, + colorCode + }, + create: { + name, + totalBudget, + colorCode, + workingGroupId, + periodId + } + }); +} + +async function main() { + const passwordHash = await bcrypt.hash("demo123!", 12); + const currentPeriod = await upsertCurrentPeriod(); + + const deko = await upsertWorkingGroup("AG Deko"); + const awareness = await upsertWorkingGroup("AG Awareness"); + const technik = await upsertWorkingGroup("AG Technik"); + + await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A"); + await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D"); + const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6"); + + await prisma.user.upsert({ + where: { username: "vorstand-a" }, + update: { + name: "Admin 1", + username: "vorstand-a", + email: null, + passwordHash, + role: Role.ADMIN, + approvalPreference: ApprovalType.CHAIR_A, + workingGroupId: null + }, + create: { + name: "Admin 1", + username: "vorstand-a", + email: null, + passwordHash, + role: Role.ADMIN, + approvalPreference: ApprovalType.CHAIR_A + } + }); + + await prisma.user.upsert({ + where: { username: "vorstand-b" }, + update: { + name: "Admin 2", + username: "vorstand-b", + email: null, + passwordHash, + role: Role.ADMIN, + approvalPreference: ApprovalType.CHAIR_B, + workingGroupId: null + }, + create: { + name: "Admin 2", + username: "vorstand-b", + email: null, + passwordHash, + role: Role.ADMIN, + approvalPreference: ApprovalType.CHAIR_B + } + }); + + await prisma.user.upsert({ + where: { username: "finanzen" }, + update: { + name: "Finanz-AG", + username: "finanzen", + email: null, + passwordHash, + role: Role.FINANCE, + approvalPreference: ApprovalType.FINANCE, + workingGroupId: null + }, + create: { + name: "Finanz-AG", + username: "finanzen", + email: null, + passwordHash, + role: Role.FINANCE, + approvalPreference: ApprovalType.FINANCE + } + }); + + await prisma.user.upsert({ + where: { username: "deko" }, + update: { + name: "Deko Mitglied", + username: "deko", + email: null, + passwordHash, + role: Role.MEMBER, + approvalPreference: null, + workingGroupId: deko.id + }, + create: { + name: "Deko Mitglied", + username: "deko", + email: null, + passwordHash, + role: Role.MEMBER, + workingGroupId: deko.id + } + }); + + await prisma.user.upsert({ + where: { username: "technik" }, + update: { + name: "Technik Mitglied", + username: "technik", + email: null, + passwordHash, + role: Role.MEMBER, + approvalPreference: null, + workingGroupId: technik.id + }, + create: { + name: "Technik Mitglied", + username: "technik", + email: null, + passwordHash, + role: Role.MEMBER, + workingGroupId: technik.id + } + }); + + const existingExpense = await prisma.expense.findFirst({ + where: { title: "Muster: Kabelbinder" } + }); + + if (!existingExpense) { + const technikUser = await prisma.user.findUniqueOrThrow({ + where: { username: "technik" } + }); + + await prisma.expense.create({ + data: { + title: "Muster: Kabelbinder", + description: "Beispiel fuer einen automatisch freigegebenen Infrastrukturposten.", + amount: 24.5, + creatorId: technikUser.id, + agId: technik.id, + budgetId: technikBudget.id, + periodId: currentPeriod.id, + approvalStatus: ApprovalStatus.APPROVED, + proofUrl: null, + recurrence: ExpenseRecurrence.NONE + } + }); + } + + const existingSubscription = await prisma.expense.findFirst({ + where: { title: "Muster: Vereinssoftware Abo" } + }); + + if (!existingSubscription) { + const financeUser = await prisma.user.findUniqueOrThrow({ + where: { username: "finanzen" } + }); + + await prisma.expense.create({ + data: { + title: "Muster: Vereinssoftware Abo", + description: "Monatlich wiederkehrendes Beispielabo fuer die Uebersicht.", + amount: 19, + creatorId: financeUser.id, + agId: technik.id, + budgetId: technikBudget.id, + periodId: currentPeriod.id, + approvalStatus: ApprovalStatus.APPROVED, + proofUrl: null, + recurrence: ExpenseRecurrence.MONTHLY + } + }); + } +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..555530d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..bddd2f3 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..051cb1e Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..60bf8a4 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..60bf8a4 Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..f604063 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..93e4f29 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,45 @@ +const CACHE_NAME = "rave-budget-control-v1"; +const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName))) + ) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + if (request.method !== "GET" || url.pathname.startsWith("/api")) { + return; + } + + event.respondWith( + fetch(request) + .then((response) => { + const responseClone = response.clone(); + + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return cached ?? caches.match("/"); + }) + ); +}); diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts new file mode 100644 index 0000000..7fc6cd5 --- /dev/null +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -0,0 +1,516 @@ +import { NextResponse } from "next/server"; + +import { createAuditLog, getRollbackMetadata } from "@/lib/audit-log"; +import { APPROVAL_FLOW, canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +type Context = { + params: { + id: string; + }; +}; + +function asRecord(value: unknown, label: string) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} ist im Änderungsverlauf nicht vollständig hinterlegt.`); + } + + return value as Record; +} + +function asString(value: unknown, label: string) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} fehlt im Änderungsverlauf.`); + } + + return value; +} + +function asNullableString(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function asDate(value: unknown, label: string) { + if (value === null || value === undefined || value === "") { + return null; + } + + const parsed = new Date(asString(value, label)); + + if (Number.isNaN(parsed.getTime())) { + throw new Error(`${label} ist ungültig.`); + } + + return parsed; +} + +function asNumber(value: unknown, label: string) { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`${label} ist ungültig.`); + } + + return value; +} + +export async function POST(_: 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 dürfen Zustände zurücksetzen." }, { status: 403 }); + } + + const auditLog = await prisma.auditLog.findUnique({ + where: { + id: params.id + } + }); + + if (!auditLog) { + return NextResponse.json({ error: "Log-Eintrag nicht gefunden." }, { status: 404 }); + } + + const rollback = getRollbackMetadata(auditLog.metadata); + + if (!rollback) { + return NextResponse.json({ error: "Dieser Log-Eintrag enthält noch keinen wiederherstellbaren Zustand." }, { status: 400 }); + } + + try { + await prisma.$transaction(async (tx) => { + switch (rollback.kind) { + case "budget.create": { + const created = asRecord(rollback.created, "Budget"); + const budgetId = asString(created.id, "Budget-ID"); + const budget = await tx.budget.findUnique({ + where: { id: budgetId }, + include: { + _count: { + select: { + expenses: true + } + } + } + }); + + if (!budget) { + throw new Error("Das angelegte Budget existiert nicht mehr."); + } + + if (budget._count.expenses > 0) { + throw new Error("Das Budget hat bereits Ausgaben und kann nicht automatisch entfernt werden."); + } + + await tx.budget.delete({ + where: { id: budgetId } + }); + + if (rollback.createdWorkingGroup) { + const createdWorkingGroup = asRecord(rollback.createdWorkingGroup, "Arbeitsgruppe"); + const workingGroupId = asString(createdWorkingGroup.id, "AG-ID"); + const workingGroup = await tx.workingGroup.findUnique({ + where: { id: workingGroupId }, + include: { + _count: { + select: { + members: true, + budgets: true, + expenses: true + } + } + } + }); + + if ( + workingGroup && + workingGroup._count.members === 0 && + workingGroup._count.budgets === 0 && + workingGroup._count.expenses === 0 + ) { + await tx.workingGroup.delete({ + where: { id: workingGroup.id } + }); + } + } + break; + } + + case "budget.update": { + const previous = asRecord(rollback.previous, "Budget"); + const budgetId = asString(previous.id, "Budget-ID"); + + await tx.budget.update({ + where: { id: budgetId }, + data: { + name: asString(previous.name, "Budgetname"), + totalBudget: asNumber(previous.totalBudget, "Budgetbetrag"), + colorCode: asString(previous.colorCode, "Budgetfarbe") + } + }); + break; + } + + case "budget.delete": { + const deleted = asRecord(rollback.deleted, "Budget"); + + await tx.budget.create({ + data: { + id: asString(deleted.id, "Budget-ID"), + name: asString(deleted.name, "Budgetname"), + totalBudget: asNumber(deleted.totalBudget, "Budgetbetrag"), + colorCode: asString(deleted.colorCode, "Budgetfarbe"), + workingGroupId: asString(deleted.workingGroupId, "AG-ID"), + periodId: asString(deleted.periodId, "Zeitraum-ID"), + createdAt: asDate(deleted.createdAt, "Budget erstellt am") ?? new Date() + } + }); + break; + } + + case "workingGroup.delete": { + const deleted = asRecord(rollback.deleted, "Arbeitsgruppe"); + + await tx.workingGroup.create({ + data: { + id: asString(deleted.id, "AG-ID"), + name: asString(deleted.name, "AG-Name"), + createdAt: asDate(deleted.createdAt, "AG erstellt am") ?? new Date() + } + }); + break; + } + + case "workingGroup.create": { + const created = asRecord(rollback.created, "Arbeitsgruppe"); + const workingGroupId = asString(created.id, "AG-ID"); + const workingGroup = await tx.workingGroup.findUnique({ + where: { id: workingGroupId }, + include: { + _count: { + select: { + members: true, + budgets: true, + expenses: true + } + } + } + }); + + if (!workingGroup) { + throw new Error("Die angelegte AG existiert nicht mehr."); + } + + if (workingGroup._count.members > 0 || workingGroup._count.budgets > 0 || workingGroup._count.expenses > 0) { + throw new Error("Die AG wird bereits verwendet und kann nicht automatisch entfernt werden."); + } + + await tx.workingGroup.delete({ + where: { id: workingGroupId } + }); + break; + } + + case "workingGroup.update": { + const previous = asRecord(rollback.previous, "Arbeitsgruppe"); + const workingGroupId = asString(previous.id, "AG-ID"); + + await tx.workingGroup.update({ + where: { id: workingGroupId }, + data: { + name: asString(previous.name, "AG-Name") + } + }); + break; + } + + case "period.create": { + const created = asRecord(rollback.created, "Zeitraum"); + const periodId = asString(created.id, "Zeitraum-ID"); + const period = await tx.accountingPeriod.findUnique({ + where: { id: periodId }, + include: { + _count: { + select: { + budgets: true, + expenses: true + } + } + } + }); + + if (!period) { + throw new Error("Der angelegte Zeitraum existiert nicht mehr."); + } + + if (period.isCurrent) { + throw new Error("Der aktuell aktive Zeitraum kann nicht automatisch entfernt werden."); + } + + if (period._count.expenses > 0) { + throw new Error("Der Zeitraum enthält bereits Ausgaben und kann nicht automatisch entfernt werden."); + } + + await tx.budget.deleteMany({ + where: { + periodId + } + }); + + await tx.accountingPeriod.delete({ + where: { + id: periodId + } + }); + break; + } + + case "period.delete": { + const deleted = asRecord(rollback.deleted, "Zeitraum"); + + await tx.accountingPeriod.create({ + data: { + id: asString(deleted.id, "Zeitraum-ID"), + name: asString(deleted.name, "Zeitraumname"), + startsAt: asDate(deleted.startsAt, "Zeitraumstart") ?? new Date(), + endsAt: asDate(deleted.endsAt, "Zeitraumende") ?? new Date(), + isCurrent: Boolean(deleted.isCurrent), + createdAt: asDate(deleted.createdAt, "Zeitraum erstellt am") ?? new Date() + } + }); + break; + } + + case "period.setCurrent": { + const previousCurrentPeriodId = asNullableString(rollback.previousCurrentPeriodId); + + await tx.accountingPeriod.updateMany({ + data: { + isCurrent: false + } + }); + + if (previousCurrentPeriodId) { + await tx.accountingPeriod.update({ + where: { + id: previousCurrentPeriodId + }, + data: { + isCurrent: true + } + }); + } + break; + } + + case "user.create": { + const created = asRecord(rollback.created, "Nutzer"); + const userId = asString(created.id, "Nutzer-ID"); + + if (viewer.id === userId) { + throw new Error("Dein eigenes aktives Konto kann nicht über den Änderungsverlauf entfernt werden."); + } + + const user = await tx.user.findUnique({ + where: { id: userId }, + include: { + _count: { + select: { + approvals: true, + createdExpenses: true + } + } + } + }); + + if (!user) { + throw new Error("Der angelegte Nutzer existiert nicht mehr."); + } + + if (user._count.approvals > 0 || user._count.createdExpenses > 0) { + throw new Error("Der Nutzer hat bereits Ausgaben oder Freigaben und kann nicht automatisch entfernt werden."); + } + + if (user.role === "ADMIN") { + const adminCount = await tx.user.count({ + where: { + role: "ADMIN" + } + }); + + if (adminCount <= 1) { + throw new Error("Mindestens ein Vorstandskonto muss erhalten bleiben."); + } + } + + await tx.user.delete({ + where: { id: userId } + }); + break; + } + + case "user.delete": { + const deleted = asRecord(rollback.deleted, "Nutzer"); + + await tx.user.create({ + data: { + id: asString(deleted.id, "Nutzer-ID"), + name: asString(deleted.name, "Anzeigename"), + username: asString(deleted.username, "Login-Name"), + email: asNullableString(deleted.email), + passwordHash: asString(deleted.passwordHash, "Passworthash"), + role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER", + approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, + workingGroupId: asNullableString(deleted.workingGroupId), + createdAt: asDate(deleted.createdAt, "Nutzer erstellt am") ?? new Date() + } + }); + break; + } + + case "user.passwordReset": { + await tx.user.update({ + where: { + id: asString(rollback.userId, "Nutzer-ID") + }, + data: { + passwordHash: asString(rollback.previousPasswordHash, "Altes Passwort") + } + }); + break; + } + + case "expense.create": { + const created = asRecord(rollback.created, "Ausgabe"); + const expenseId = asString(created.id, "Ausgabe-ID"); + const expense = await tx.expense.findUnique({ + where: { id: expenseId }, + include: { + _count: { + select: { + approvals: true + } + } + } + }); + + if (!expense) { + throw new Error("Die angelegte Ausgabe existiert nicht mehr."); + } + + if (expense._count.approvals > 0 || expense.paidAt || expense.documentedAt) { + throw new Error("Die Ausgabe wurde bereits weiterverarbeitet und kann nicht automatisch entfernt werden."); + } + + await tx.expense.delete({ + where: { id: expenseId } + }); + break; + } + + case "expense.delete": { + const deleted = asRecord(rollback.deleted, "Ausgabe"); + + await tx.expense.create({ + data: { + id: asString(deleted.id, "Ausgabe-ID"), + title: asString(deleted.title, "Titel"), + description: asNullableString(deleted.description), + amount: asNumber(deleted.amount, "Betrag"), + creatorId: asString(deleted.creatorId, "Ersteller-ID"), + agId: asString(deleted.agId, "AG-ID"), + budgetId: asString(deleted.budgetId, "Budget-ID"), + periodId: asString(deleted.periodId, "Zeitraum-ID"), + approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED", + recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY", + proofUrl: asNullableString(deleted.proofUrl), + createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(), + paidAt: asDate(deleted.paidAt, "Bezahlt am"), + documentedAt: asDate(deleted.documentedAt, "Dokumentiert am") + } + }); + break; + } + + case "expense.approve": { + const approval = asRecord(rollback.approval, "Freigabe"); + const expenseId = asString(approval.expenseId, "Ausgabe-ID"); + + await tx.approval.delete({ + where: { + id: asString(approval.id, "Freigabe-ID") + } + }); + + const remainingApprovals = await tx.approval.findMany({ + where: { + expenseId + } + }); + + const approvalTypes = remainingApprovals.map((entry) => entry.approvalType); + const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType)) + ? "APPROVED" + : "PENDING"; + + await tx.expense.update({ + where: { id: expenseId }, + data: { + approvalStatus + } + }); + break; + } + + case "expense.markPaid": { + await tx.expense.update({ + where: { + id: asString(rollback.expenseId, "Ausgabe-ID") + }, + data: { + paidAt: asDate(rollback.previousPaidAt, "Vorheriger Bezahlt-Zeitpunkt") + } + }); + break; + } + + case "expense.document": { + await tx.expense.update({ + where: { + id: asString(rollback.expenseId, "Ausgabe-ID") + }, + data: { + proofUrl: asNullableString(rollback.previousProofUrl), + documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt") + } + }); + break; + } + + default: + throw new Error("Dieser Änderungstyp kann aktuell noch nicht automatisch zurückgesetzt werden."); + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "audit.restore", + entityType: auditLog.entityType, + entityId: auditLog.entityId, + entityLabel: auditLog.entityLabel, + summary: `Änderung "${auditLog.summary}" wurde zurückgesetzt.`, + metadata: { + restoredAuditLogId: auditLog.id, + restoredAction: auditLog.action + } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Der Zustand konnte nicht zurückgesetzt werden."; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..587c804 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,8 @@ +import NextAuth from "next-auth"; + +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; + diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts new file mode 100644 index 0000000..a61ff53 --- /dev/null +++ b/src/app/api/budgets/[id]/route.ts @@ -0,0 +1,147 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotBudget } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const updateBudgetSchema = z.object({ + name: z.string().trim().min(2).max(80), + totalBudget: z.coerce.number().min(0), + colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) +}); + +type Context = { + params: { + id: string; + }; +}; + +export async function PATCH(request: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets aendern." }, { status: 403 }); + } + + const budget = await prisma.budget.findUnique({ + where: { id: params.id } + }); + + if (!budget) { + return NextResponse.json({ error: "Budget nicht gefunden." }, { status: 404 }); + } + + const body = await request.json().catch(() => null); + const parsed = updateBudgetSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Budgetname, Betrag oder Farbe sind ungueltig." }, { status: 400 }); + } + + try { + const previousBudget = budget; + const updatedBudget = await prisma.budget.update({ + where: { id: params.id }, + data: { + name: parsed.data.name, + totalBudget: parsed.data.totalBudget, + colorCode: parsed.data.colorCode + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "budget.update", + entityType: "budget", + entityId: updatedBudget.id, + entityLabel: updatedBudget.name, + summary: `Budget ${updatedBudget.name} wurde aktualisiert.`, + metadata: { + totalBudget: parsed.data.totalBudget, + colorCode: parsed.data.colorCode, + rollback: { + kind: "budget.update", + previous: snapshotBudget(previousBudget), + next: snapshotBudget(updatedBudget) + } + } + }); + + return NextResponse.json({ budget: updatedBudget }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return NextResponse.json( + { error: "In dieser AG gibt es bereits ein Budget mit diesem Namen." }, + { status: 409 } + ); + } + + throw error; + } +} + +export async function DELETE(_: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets loeschen." }, { status: 403 }); + } + + const budget = await prisma.budget.findUnique({ + where: { id: params.id }, + include: { + _count: { + select: { + expenses: true + } + } + } + }); + + if (!budget) { + return NextResponse.json({ error: "Budget nicht gefunden." }, { status: 404 }); + } + + if (budget._count.expenses > 0) { + return NextResponse.json( + { error: "Dieses Budget enthaelt noch Ausgaben. Bitte loesche oder verschiebe erst die Posten." }, + { status: 400 } + ); + } + + await prisma.budget.delete({ + where: { id: params.id } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "budget.delete", + entityType: "budget", + entityId: budget.id, + entityLabel: budget.name, + summary: `Budget ${budget.name} wurde geloescht.`, + metadata: { + rollback: { + kind: "budget.delete", + deleted: snapshotBudget(budget) + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts new file mode 100644 index 0000000..c79240e --- /dev/null +++ b/src/app/api/budgets/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotBudget } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const budgetSchema = z.object({ + workingGroupId: z.string().trim().min(1), + periodId: z.string().trim().min(1), + name: z.string().trim().min(2).max(80), + totalBudget: z.coerce.number().min(0), + colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) +}); + +export async function POST(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets verwalten." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = budgetSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte AG, Budgetname, Betrag und Farbe korrekt angeben." }, { status: 400 }); + } + + const workingGroup = await prisma.workingGroup.findUnique({ + where: { + id: parsed.data.workingGroupId + } + }); + + if (!workingGroup) { + return NextResponse.json({ error: "Die ausgewaehlte AG wurde nicht gefunden." }, { status: 404 }); + } + + const accountingPeriod = await prisma.accountingPeriod.findUnique({ + where: { + id: parsed.data.periodId + } + }); + + if (!accountingPeriod) { + return NextResponse.json({ error: "Der ausgewaehlte Abrechnungszeitraum wurde nicht gefunden." }, { status: 404 }); + } + + const existingBudget = await prisma.budget.findUnique({ + where: { + workingGroupId_periodId_name: { + workingGroupId: workingGroup.id, + periodId: accountingPeriod.id, + name: parsed.data.name + } + } + }); + + const budget = existingBudget + ? await prisma.budget.update({ + where: { + id: existingBudget.id + }, + data: { + totalBudget: parsed.data.totalBudget, + colorCode: parsed.data.colorCode + } + }) + : await prisma.budget.create({ + data: { + workingGroupId: workingGroup.id, + periodId: accountingPeriod.id, + name: parsed.data.name, + totalBudget: parsed.data.totalBudget, + colorCode: parsed.data.colorCode + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: existingBudget ? "budget.update" : "budget.create", + entityType: "budget", + entityId: budget.id, + entityLabel: budget.name, + summary: existingBudget + ? `Budget ${budget.name} in ${workingGroup.name} gespeichert.` + : `Budget ${budget.name} in ${workingGroup.name} angelegt.`, + metadata: { + workingGroupId: workingGroup.id, + workingGroupName: workingGroup.name, + periodId: accountingPeriod.id, + periodName: accountingPeriod.name, + totalBudget: parsed.data.totalBudget, + rollback: existingBudget + ? { + kind: "budget.update", + previous: snapshotBudget(existingBudget), + next: snapshotBudget(budget) + } + : { + kind: "budget.create", + created: snapshotBudget(budget) + } + } + }); + + return NextResponse.json({ budget }); +} diff --git a/src/app/api/expenses/[id]/approve/route.ts b/src/app/api/expenses/[id]/approve/route.ts new file mode 100644 index 0000000..13e33fb --- /dev/null +++ b/src/app/api/expenses/[id]/approve/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotApproval } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { APPROVAL_FLOW, getAvailableApprovalTypes, requiresManualApproval } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const approvalSchema = z.object({ + approvalType: z.enum(APPROVAL_FLOW) +}); + +type Context = { + params: { + id: string; + }; +}; + +export async function POST(request: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + const expense = await prisma.expense.findUnique({ + where: { id: params.id }, + include: { + approvals: true + } + }); + + if (!expense) { + return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); + } + + if (!requiresManualApproval(Number(expense.amount))) { + return NextResponse.json({ error: "Diese Ausgabe ist bereits automatisch freigegeben." }, { status: 400 }); + } + + const body = await request.json().catch(() => null); + const parsed = approvalSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Freigabetyp ungueltig." }, { status: 400 }); + } + + const existingApprovals = expense.approvals.map((approval) => approval.approvalType); + const availableApprovals = getAvailableApprovalTypes( + viewer.role, + viewer.approvalPreference, + existingApprovals + ); + + if (!availableApprovals.includes(parsed.data.approvalType)) { + return NextResponse.json({ error: "Du darfst diese Freigabe nicht setzen." }, { status: 403 }); + } + + const transactionResult = await prisma.$transaction(async (tx) => { + const existingApproval = await tx.approval.findUnique({ + where: { + expenseId_approvalType: { + expenseId: expense.id, + approvalType: parsed.data.approvalType + } + } + }); + + const createdApproval = + existingApproval ?? + (await tx.approval.create({ + data: { + expenseId: expense.id, + userId: viewer.id, + approvalType: parsed.data.approvalType + } + })); + + const approvals = await tx.approval.findMany({ + where: { + expenseId: expense.id + } + }); + + const approvalTypes = approvals.map((approval) => approval.approvalType); + const approvalStatus = APPROVAL_FLOW.every((approvalType) => approvalTypes.includes(approvalType)) + ? "APPROVED" + : "PENDING"; + + await tx.expense.update({ + where: { id: expense.id }, + data: { + approvalStatus + } + }); + + return { + approval: createdApproval, + previousStatus: expense.approvalStatus, + nextStatus: approvalStatus + }; + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.approve", + entityType: "expense", + entityId: expense.id, + entityLabel: expense.title, + summary: `${parsed.data.approvalType} fuer ${expense.title} wurde gesetzt.`, + metadata: { + approvalType: parsed.data.approvalType, + rollback: { + kind: "expense.approve", + approval: snapshotApproval(transactionResult.approval), + previousStatus: transactionResult.previousStatus, + nextStatus: transactionResult.nextStatus + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/expenses/[id]/documented/route.ts b/src/app/api/expenses/[id]/documented/route.ts new file mode 100644 index 0000000..44f1043 --- /dev/null +++ b/src/app/api/expenses/[id]/documented/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createAuditLog } from "@/lib/audit-log"; +import { canDocumentExpense } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const documentedSchema = z.object({ + proofUrl: z + .union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) + .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)) +}); + +type Context = { + params: { + id: string; + }; +}; + +export async function POST(request: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canDocumentExpense(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen dokumentieren." }, { status: 403 }); + } + + const expense = await prisma.expense.findUnique({ + where: { id: params.id } + }); + + if (!expense) { + return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); + } + + if (expense.approvalStatus !== "APPROVED") { + return NextResponse.json({ error: "Dokumentation ist erst nach Freigabe moeglich." }, { status: 400 }); + } + + if (!expense.paidAt) { + return NextResponse.json({ error: "Bitte zuerst Bezahlt setzen." }, { status: 400 }); + } + + const body = await request.json().catch(() => ({})); + const parsed = documentedSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Beleg-URL ist ungueltig." }, { status: 400 }); + } + + const updatedExpense = await prisma.expense.update({ + where: { id: params.id }, + data: { + proofUrl: parsed.data.proofUrl ?? expense.proofUrl, + documentedAt: expense.documentedAt ?? new Date() + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.document", + entityType: "expense", + entityId: updatedExpense.id, + entityLabel: updatedExpense.title, + summary: `Ausgabe ${updatedExpense.title} wurde dokumentiert.`, + metadata: { + proofUrl: parsed.data.proofUrl ?? updatedExpense.proofUrl, + rollback: { + kind: "expense.document", + expenseId: updatedExpense.id, + previousProofUrl: expense.proofUrl, + previousDocumentedAt: expense.documentedAt?.toISOString() ?? null, + nextProofUrl: updatedExpense.proofUrl, + nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null + } + } + }); + + return NextResponse.json({ expense: updatedExpense }); +} diff --git a/src/app/api/expenses/[id]/paid/route.ts b/src/app/api/expenses/[id]/paid/route.ts new file mode 100644 index 0000000..73e0ba3 --- /dev/null +++ b/src/app/api/expenses/[id]/paid/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; + +import { createAuditLog } from "@/lib/audit-log"; +import { canMarkPaid } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +type Context = { + params: { + id: string; + }; +}; + +export async function POST(_: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canMarkPaid(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Bezahlt setzen." }, { status: 403 }); + } + + const expense = await prisma.expense.findUnique({ + where: { id: params.id } + }); + + if (!expense) { + return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); + } + + if (expense.approvalStatus !== "APPROVED") { + return NextResponse.json({ error: "Bezahlt ist erst nach Freigabe moeglich." }, { status: 400 }); + } + + const updatedExpense = await prisma.expense.update({ + where: { id: params.id }, + data: { + paidAt: expense.paidAt ?? new Date() + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.markPaid", + entityType: "expense", + entityId: updatedExpense.id, + entityLabel: updatedExpense.title, + summary: `Ausgabe ${updatedExpense.title} wurde als bezahlt markiert.`, + metadata: { + rollback: { + kind: "expense.markPaid", + expenseId: updatedExpense.id, + previousPaidAt: expense.paidAt?.toISOString() ?? null, + nextPaidAt: updatedExpense.paidAt?.toISOString() ?? null + } + } + }); + + return NextResponse.json({ expense: updatedExpense }); +} diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts new file mode 100644 index 0000000..7de6a5b --- /dev/null +++ b/src/app/api/expenses/[id]/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; + +import { snapshotExpense } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +type Context = { + params: { + id: string; + }; +}; + +export async function DELETE(_: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + const expense = await prisma.expense.findUnique({ + where: { id: params.id } + }); + + if (!expense) { + return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 }); + } + + const isAdminDelete = viewer.role === "ADMIN" || viewer.role === "FINANCE"; + const isOwnPendingExpense = + viewer.id === expense.creatorId && + expense.approvalStatus === "PENDING" && + !expense.paidAt && + !expense.documentedAt; + + if (!isAdminDelete && !isOwnPendingExpense) { + return NextResponse.json( + { error: "Du darfst nur eigene ungepruefte Ausgaben loeschen." }, + { status: 403 } + ); + } + + await prisma.expense.delete({ + where: { id: params.id } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.delete", + entityType: "expense", + entityId: expense.id, + entityLabel: expense.title, + summary: `Ausgabe ${expense.title} wurde geloescht.`, + metadata: { + rollback: { + kind: "expense.delete", + deleted: snapshotExpense(expense) + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts new file mode 100644 index 0000000..f9d77fe --- /dev/null +++ b/src/app/api/expenses/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotExpense } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const expenseSchema = z.object({ + title: z.string().trim().min(2).max(120), + description: z + .union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()]) + .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)), + amount: z.coerce.number().positive(), + agId: z.string().trim().min(1), + budgetId: z.string().trim().min(1), + recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"), + proofUrl: z + .union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) + .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)), +}); + +export async function POST(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + const parsed = expenseSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte Titel, Betrag und AG korrekt ausfuellen." }, { status: 400 }); + } + + if (!canCreateExpenseForGroup(viewer.role, viewer.workingGroupId, parsed.data.agId)) { + return NextResponse.json({ error: "Du kannst nur in deiner eigenen AG Ausgaben erfassen." }, { status: 403 }); + } + + const budget = await prisma.budget.findUnique({ + where: { id: parsed.data.budgetId } + }); + + if (!budget || budget.workingGroupId !== parsed.data.agId) { + return NextResponse.json({ error: "Das ausgewaehlte Budget passt nicht zur AG." }, { status: 404 }); + } + + const expense = await prisma.expense.create({ + data: { + title: parsed.data.title, + description: parsed.data.description, + amount: parsed.data.amount, + agId: parsed.data.agId, + budgetId: parsed.data.budgetId, + periodId: budget.periodId, + creatorId: viewer.id, + proofUrl: parsed.data.proofUrl, + recurrence: parsed.data.recurrence, + approvalStatus: requiresManualApproval(parsed.data.amount) ? "PENDING" : "APPROVED" + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.create", + entityType: "expense", + entityId: expense.id, + entityLabel: expense.title, + summary: `Ausgabe ${expense.title} wurde angelegt.`, + metadata: { + amount: parsed.data.amount, + budgetId: parsed.data.budgetId, + workingGroupId: parsed.data.agId, + recurrence: parsed.data.recurrence, + approvalStatus: expense.approvalStatus, + rollback: { + kind: "expense.create", + created: snapshotExpense(expense) + } + } + }); + + return NextResponse.json({ expense }); +} diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts new file mode 100644 index 0000000..0a9febd --- /dev/null +++ b/src/app/api/export/csv/route.ts @@ -0,0 +1,518 @@ +import { NextResponse } from "next/server"; + +import { toCsvCell } from "@/lib/backup-csv"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const CSV_HEADERS = [ + "recordType", + "id", + "parentId", + "parentType", + "workingGroupId", + "workingGroupName", + "periodId", + "periodName", + "periodStartsAt", + "periodEndsAt", + "periodIsCurrent", + "budgetId", + "budgetName", + "userId", + "userName", + "username", + "passwordHash", + "email", + "role", + "approvalPreference", + "title", + "description", + "amount", + "totalBudget", + "colorCode", + "approvalStatus", + "approvalType", + "recurrence", + "proofUrl", + "createdAt", + "paidAt", + "documentedAt", + "memberUsernames", + "creatorName", + "creatorUsername", + "approverName", + "approverUsername", + "auditActorId", + "auditAction", + "auditEntityType", + "auditEntityId", + "auditEntityLabel", + "auditSummary", + "auditMetadata" +] as const; + +type CsvRow = Record<(typeof CSV_HEADERS)[number], string | number | null | undefined>; + +export async function GET() { + 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 CSV-Backups herunterladen." }, { status: 403 }); + } + + const [users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([ + prisma.user.findMany({ + include: { + workingGroup: { + select: { + name: true + } + } + }, + orderBy: [ + { role: "asc" }, + { username: "asc" } + ] + }), + prisma.accountingPeriod.findMany({ + orderBy: { + startsAt: "asc" + } + }), + prisma.workingGroup.findMany({ + orderBy: { + name: "asc" + }, + include: { + members: { + select: { + id: true, + name: true, + username: true, + email: true, + role: true, + approvalPreference: true + }, + orderBy: { + username: "asc" + } + }, + budgets: { + orderBy: { + name: "asc" + }, + include: { + period: { + select: { + id: true, + name: true, + startsAt: true, + endsAt: true, + isCurrent: true + } + }, + expenses: { + orderBy: { + createdAt: "asc" + }, + include: { + creator: { + select: { + id: true, + name: true, + username: true + } + }, + approvals: { + orderBy: { + timestamp: "asc" + }, + include: { + user: { + select: { + id: true, + name: true, + username: true + } + } + } + } + } + } + } + } + } + }), + prisma.auditLog.findMany({ + orderBy: { + createdAt: "asc" + } + }) + ]); + + const rows: CsvRow[] = []; + + for (const user of users) { + rows.push({ + recordType: "user", + id: user.id, + parentId: user.workingGroupId, + parentType: user.workingGroupId ? "workingGroup" : "", + workingGroupId: user.workingGroupId, + workingGroupName: user.workingGroup?.name ?? "", + periodId: "", + periodName: "", + periodStartsAt: "", + periodEndsAt: "", + periodIsCurrent: "", + budgetId: "", + budgetName: "", + userId: user.id, + userName: user.name, + username: user.username, + passwordHash: user.passwordHash, + email: user.email, + role: user.role, + approvalPreference: user.approvalPreference ?? "", + title: "", + description: "", + amount: "", + totalBudget: "", + colorCode: "", + approvalStatus: "", + approvalType: "", + recurrence: "", + proofUrl: "", + createdAt: user.createdAt.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: "", + creatorName: "", + creatorUsername: "", + approverName: "", + approverUsername: "", + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + } + + for (const period of accountingPeriods) { + rows.push({ + recordType: "period", + id: period.id, + parentId: "", + parentType: "", + workingGroupId: "", + workingGroupName: "", + periodId: period.id, + periodName: period.name, + periodStartsAt: period.startsAt.toISOString(), + periodEndsAt: period.endsAt.toISOString(), + periodIsCurrent: period.isCurrent ? "true" : "false", + budgetId: "", + budgetName: "", + userId: "", + userName: "", + username: "", + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: "", + description: "", + amount: "", + totalBudget: "", + colorCode: "", + approvalStatus: "", + approvalType: "", + recurrence: "", + proofUrl: "", + createdAt: period.createdAt.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: "", + creatorName: "", + creatorUsername: "", + approverName: "", + approverUsername: "", + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + } + + for (const group of workingGroups) { + rows.push({ + recordType: "workingGroup", + id: group.id, + parentId: "", + parentType: "", + workingGroupId: group.id, + workingGroupName: group.name, + periodId: "", + periodName: "", + periodStartsAt: "", + periodEndsAt: "", + periodIsCurrent: "", + budgetId: "", + budgetName: "", + userId: "", + userName: "", + username: "", + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: "", + description: "", + amount: "", + totalBudget: group.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0).toFixed(2), + colorCode: "", + approvalStatus: "", + approvalType: "", + recurrence: "", + proofUrl: "", + createdAt: group.createdAt.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: group.members.map((member) => member.username).join(" | "), + creatorName: "", + creatorUsername: "", + approverName: "", + approverUsername: "", + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + + for (const budget of group.budgets) { + rows.push({ + recordType: "budget", + id: budget.id, + parentId: group.id, + parentType: "workingGroup", + workingGroupId: group.id, + workingGroupName: group.name, + periodId: budget.period.id, + periodName: budget.period.name, + periodStartsAt: budget.period.startsAt.toISOString(), + periodEndsAt: budget.period.endsAt.toISOString(), + periodIsCurrent: budget.period.isCurrent ? "true" : "false", + budgetId: budget.id, + budgetName: budget.name, + userId: "", + userName: "", + username: "", + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: "", + description: "", + amount: "", + totalBudget: Number(budget.totalBudget).toFixed(2), + colorCode: budget.colorCode, + approvalStatus: "", + approvalType: "", + recurrence: "", + proofUrl: "", + createdAt: budget.createdAt.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: "", + creatorName: "", + creatorUsername: "", + approverName: "", + approverUsername: "", + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + + for (const expense of budget.expenses) { + rows.push({ + recordType: "expense", + id: expense.id, + parentId: budget.id, + parentType: "budget", + workingGroupId: group.id, + workingGroupName: group.name, + periodId: budget.period.id, + periodName: budget.period.name, + periodStartsAt: budget.period.startsAt.toISOString(), + periodEndsAt: budget.period.endsAt.toISOString(), + periodIsCurrent: budget.period.isCurrent ? "true" : "false", + budgetId: budget.id, + budgetName: budget.name, + userId: expense.creator.id, + userName: expense.creator.name, + username: expense.creator.username, + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: expense.title, + description: expense.description ?? "", + amount: Number(expense.amount).toFixed(2), + totalBudget: "", + colorCode: "", + approvalStatus: expense.approvalStatus, + approvalType: "", + recurrence: expense.recurrence, + proofUrl: expense.proofUrl ?? "", + createdAt: expense.createdAt.toISOString(), + paidAt: expense.paidAt?.toISOString() ?? "", + documentedAt: expense.documentedAt?.toISOString() ?? "", + memberUsernames: "", + creatorName: expense.creator.name, + creatorUsername: expense.creator.username, + approverName: "", + approverUsername: "", + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + + for (const approval of expense.approvals) { + rows.push({ + recordType: "approval", + id: approval.id, + parentId: expense.id, + parentType: "expense", + workingGroupId: group.id, + workingGroupName: group.name, + periodId: budget.period.id, + periodName: budget.period.name, + periodStartsAt: budget.period.startsAt.toISOString(), + periodEndsAt: budget.period.endsAt.toISOString(), + periodIsCurrent: budget.period.isCurrent ? "true" : "false", + budgetId: budget.id, + budgetName: budget.name, + userId: approval.user.id, + userName: approval.user.name, + username: approval.user.username, + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: expense.title, + description: "", + amount: Number(expense.amount).toFixed(2), + totalBudget: "", + colorCode: "", + approvalStatus: expense.approvalStatus, + approvalType: approval.approvalType, + recurrence: expense.recurrence, + proofUrl: "", + createdAt: approval.timestamp.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: "", + creatorName: expense.creator.name, + creatorUsername: expense.creator.username, + approverName: approval.user.name, + approverUsername: approval.user.username, + auditActorId: "", + auditAction: "", + auditEntityType: "", + auditEntityId: "", + auditEntityLabel: "", + auditSummary: "", + auditMetadata: "" + }); + } + } + } + } + + for (const auditLog of auditLogs) { + rows.push({ + recordType: "auditLog", + id: auditLog.id, + parentId: "", + parentType: "", + workingGroupId: "", + workingGroupName: "", + periodId: "", + periodName: "", + periodStartsAt: "", + periodEndsAt: "", + periodIsCurrent: "", + budgetId: "", + budgetName: "", + userId: "", + userName: "", + username: "", + passwordHash: "", + email: "", + role: "", + approvalPreference: "", + title: "", + description: "", + amount: "", + totalBudget: "", + colorCode: "", + approvalStatus: "", + approvalType: "", + recurrence: "", + proofUrl: "", + createdAt: auditLog.createdAt.toISOString(), + paidAt: "", + documentedAt: "", + memberUsernames: "", + creatorName: "", + creatorUsername: "", + approverName: "", + approverUsername: "", + auditActorId: auditLog.actorId ?? "", + auditAction: auditLog.action, + auditEntityType: auditLog.entityType, + auditEntityId: auditLog.entityId ?? "", + auditEntityLabel: auditLog.entityLabel ?? "", + auditSummary: auditLog.summary, + auditMetadata: auditLog.metadata ? JSON.stringify(auditLog.metadata) : "" + }); + } + + const csvLines = [ + CSV_HEADERS.join(","), + ...rows.map((row) => CSV_HEADERS.map((header) => toCsvCell(row[header])).join(",")) + ]; + + const timestamp = new Date().toISOString().slice(0, 10); + const csv = `\uFEFF${csvLines.join("\n")}`; + + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="rfp-finanzuebersicht-backup-${timestamp}.csv"`, + "Cache-Control": "no-store" + } + }); +} diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts new file mode 100644 index 0000000..634a996 --- /dev/null +++ b/src/app/api/import/csv/route.ts @@ -0,0 +1,239 @@ +import { NextResponse } from "next/server"; + +import { createAuditLog } from "@/lib/audit-log"; +import { parseCsv } from "@/lib/backup-csv"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +function toNullable(value: string | undefined) { + return value && value.length > 0 ? value : null; +} + +function toDate(value: string | undefined) { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function toNumber(value: string | undefined) { + if (!value || value.length === 0) { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export async function POST(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 Backups einspielen." }, { status: 403 }); + } + + const formData = await request.formData().catch(() => null); + const uploadedFile = formData?.get("file"); + + if (!(uploadedFile instanceof File)) { + return NextResponse.json({ error: "Bitte eine CSV-Datei auswaehlen." }, { status: 400 }); + } + + const content = await uploadedFile.text(); + const rows = parseCsv(content); + + if (rows.length < 2) { + return NextResponse.json({ error: "Die CSV-Datei enthaelt keine Daten." }, { status: 400 }); + } + + const headers = rows[0]; + const rawEntries = rows + .slice(1) + .filter((row) => row.some((cell) => cell.trim().length > 0)) + .map((row) => { + const entry = Object.fromEntries(headers.map((header, index) => [header, row[index] ?? ""])); + return entry as Record; + }); + + const userRows = rawEntries.filter((entry) => entry.recordType === "user"); + + if (userRows.some((entry) => !entry.passwordHash)) { + return NextResponse.json( + { error: "Dieses Backup stammt aus einem alten Format ohne Passwort-Hashes und kann nicht vollstaendig eingespielt werden." }, + { status: 400 } + ); + } + + const periodRows = rawEntries.filter((entry) => entry.recordType === "period"); + const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup"); + const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget"); + const expenseRows = rawEntries.filter((entry) => entry.recordType === "expense"); + const approvalRows = rawEntries.filter((entry) => entry.recordType === "approval"); + const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog"); + + try { + await prisma.$transaction(async (tx) => { + await tx.approval.deleteMany(); + await tx.expense.deleteMany(); + await tx.budget.deleteMany(); + await tx.auditLog.deleteMany(); + await tx.user.deleteMany(); + await tx.workingGroup.deleteMany(); + await tx.accountingPeriod.deleteMany(); + + for (const row of periodRows) { + const startsAt = toDate(row.periodStartsAt); + const endsAt = toDate(row.periodEndsAt); + + if (!startsAt || !endsAt) { + throw new Error(`Zeitraum ${row.periodName || row.id} enthaelt kein gueltiges Datum.`); + } + + await tx.accountingPeriod.create({ + data: { + id: row.id, + name: row.periodName, + startsAt, + endsAt, + isCurrent: row.periodIsCurrent === "true", + createdAt: toDate(row.createdAt) ?? new Date() + } + }); + } + + for (const row of groupRows) { + await tx.workingGroup.create({ + data: { + id: row.id, + name: row.workingGroupName, + createdAt: toDate(row.createdAt) ?? new Date() + } + }); + } + + for (const row of userRows) { + await tx.user.create({ + data: { + id: row.id, + name: row.userName, + username: row.username, + email: toNullable(row.email), + passwordHash: row.passwordHash, + role: row.role as "ADMIN" | "FINANCE" | "MEMBER", + approvalPreference: toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null, + workingGroupId: toNullable(row.workingGroupId), + createdAt: toDate(row.createdAt) ?? new Date() + } + }); + } + + for (const row of budgetRows) { + const totalBudget = toNumber(row.totalBudget); + + if (totalBudget === null) { + throw new Error(`Budget ${row.budgetName || row.id} enthaelt keinen gueltigen Betrag.`); + } + + await tx.budget.create({ + data: { + id: row.id, + name: row.budgetName, + totalBudget, + colorCode: row.colorCode, + workingGroupId: row.workingGroupId, + periodId: row.periodId, + createdAt: toDate(row.createdAt) ?? new Date() + } + }); + } + + for (const row of expenseRows) { + const amount = toNumber(row.amount); + + if (amount === null) { + throw new Error(`Ausgabe ${row.title || row.id} enthaelt keinen gueltigen Betrag.`); + } + + await tx.expense.create({ + data: { + id: row.id, + title: row.title, + description: toNullable(row.description), + amount, + creatorId: row.userId, + agId: row.workingGroupId, + budgetId: row.budgetId, + periodId: row.periodId, + approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING", + recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE", + proofUrl: toNullable(row.proofUrl), + createdAt: toDate(row.createdAt) ?? new Date(), + paidAt: toDate(row.paidAt), + documentedAt: toDate(row.documentedAt) + } + }); + } + + for (const row of approvalRows) { + const timestamp = toDate(row.createdAt); + + if (!timestamp) { + throw new Error(`Freigabe ${row.id} enthaelt keinen gueltigen Zeitstempel.`); + } + + await tx.approval.create({ + data: { + id: row.id, + expenseId: row.parentId, + userId: row.userId, + approvalType: row.approvalType as "CHAIR_A" | "CHAIR_B" | "FINANCE", + timestamp + } + }); + } + + for (const row of auditRows) { + await tx.auditLog.create({ + data: { + id: row.id, + actorId: toNullable(row.auditActorId), + action: row.auditAction, + entityType: row.auditEntityType, + entityId: toNullable(row.auditEntityId), + entityLabel: toNullable(row.auditEntityLabel), + summary: row.auditSummary, + metadata: row.auditMetadata ? JSON.parse(row.auditMetadata) : null, + createdAt: toDate(row.createdAt) ?? new Date() + } + }); + } + }); + + await createAuditLog(prisma, { + actorId: userRows.some((row) => row.id === viewer.id) ? viewer.id : null, + action: "backup.import", + entityType: "system", + entityLabel: uploadedFile.name, + summary: `CSV-Backup ${uploadedFile.name} wurde eingespielt.`, + metadata: { + fileName: uploadedFile.name, + rowCount: rawEntries.length + } + }); + + return NextResponse.json({ + ok: true, + importedRows: rawEntries.length + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Das Backup konnte nicht eingespielt werden."; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/periods/[id]/route.ts b/src/app/api/periods/[id]/route.ts new file mode 100644 index 0000000..00e5eab --- /dev/null +++ b/src/app/api/periods/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; + +import { snapshotPeriod } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +type Context = { + params: { + id: string; + }; +}; + +export async function DELETE(_: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Zeitraeume loeschen." }, { status: 403 }); + } + + const period = await prisma.accountingPeriod.findUnique({ + where: { id: params.id }, + include: { + _count: { + select: { + budgets: true, + expenses: true + } + } + } + }); + + if (!period) { + return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 }); + } + + if (period.isCurrent) { + return NextResponse.json({ error: "Der aktuell aktive Zeitraum kann nicht geloescht werden." }, { status: 400 }); + } + + if (period._count.budgets > 0 || period._count.expenses > 0) { + return NextResponse.json( + { error: "Dieser Zeitraum enthaelt noch Budgets oder Ausgaben und kann deshalb nicht geloescht werden." }, + { status: 400 } + ); + } + + await prisma.accountingPeriod.delete({ + where: { id: params.id } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "period.delete", + entityType: "period", + entityId: period.id, + entityLabel: period.name, + summary: `Zeitraum ${period.name} wurde geloescht.`, + metadata: { + rollback: { + kind: "period.delete", + deleted: snapshotPeriod(period) + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/periods/current/route.ts b/src/app/api/periods/current/route.ts new file mode 100644 index 0000000..6a45248 --- /dev/null +++ b/src/app/api/periods/current/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const currentPeriodSchema = z.object({ + periodId: z.string().trim().min(1) +}); + +export async function PATCH(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen den aktuellen Zeitraum wechseln." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = currentPeriodSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte einen gueltigen Zeitraum auswaehlen." }, { status: 400 }); + } + + const period = await prisma.accountingPeriod.findUnique({ + where: { + id: parsed.data.periodId + } + }); + + if (!period) { + return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 }); + } + + const previousCurrentPeriod = await prisma.accountingPeriod.findFirst({ + where: { + isCurrent: true + } + }); + + await prisma.$transaction([ + prisma.accountingPeriod.updateMany({ + data: { + isCurrent: false + } + }), + prisma.accountingPeriod.update({ + where: { + id: period.id + }, + data: { + isCurrent: true + } + }) + ]); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "period.setCurrent", + entityType: "period", + entityId: period.id, + entityLabel: period.name, + summary: `Aktiver Zeitraum wurde auf ${period.name} gesetzt.`, + metadata: { + rollback: { + kind: "period.setCurrent", + previousCurrentPeriodId: previousCurrentPeriod?.id ?? null, + nextCurrentPeriodId: period.id + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/periods/route.ts b/src/app/api/periods/route.ts new file mode 100644 index 0000000..d26daa1 --- /dev/null +++ b/src/app/api/periods/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotPeriod } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const periodSchema = z.object({ + name: z.string().trim().min(2).max(80), + startsAt: z.coerce.date(), + endsAt: z.coerce.date(), + copyBudgetsFromPeriodId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]) +}); + +export async function POST(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Zeitraeume verwalten." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = periodSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte Zeitraumname sowie Start- und Enddatum korrekt angeben." }, { status: 400 }); + } + + if (parsed.data.endsAt < parsed.data.startsAt) { + return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 }); + } + + const copyBudgetsFromPeriodId = + typeof parsed.data.copyBudgetsFromPeriodId === "string" && parsed.data.copyBudgetsFromPeriodId.length > 0 + ? parsed.data.copyBudgetsFromPeriodId + : null; + + const period = await prisma.$transaction(async (tx) => { + const createdPeriod = await tx.accountingPeriod.create({ + data: { + name: parsed.data.name, + startsAt: parsed.data.startsAt, + endsAt: parsed.data.endsAt, + isCurrent: false + } + }); + + if (copyBudgetsFromPeriodId) { + const sourceBudgets = await tx.budget.findMany({ + where: { + periodId: copyBudgetsFromPeriodId + } + }); + + if (sourceBudgets.length > 0) { + await tx.budget.createMany({ + data: sourceBudgets.map((budget) => ({ + name: budget.name, + totalBudget: budget.totalBudget, + colorCode: budget.colorCode, + workingGroupId: budget.workingGroupId, + periodId: createdPeriod.id + })) + }); + } + } + + return createdPeriod; + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "period.create", + entityType: "period", + entityId: period.id, + entityLabel: period.name, + summary: `Zeitraum ${period.name} wurde angelegt.`, + metadata: { + startsAt: period.startsAt.toISOString(), + endsAt: period.endsAt.toISOString(), + copiedFromPeriodId: copyBudgetsFromPeriodId, + rollback: { + kind: "period.create", + created: snapshotPeriod(period) + } + } + }); + + return NextResponse.json({ period }); +} diff --git a/src/app/api/users/[id]/password/route.ts b/src/app/api/users/[id]/password/route.ts new file mode 100644 index 0000000..fad5beb --- /dev/null +++ b/src/app/api/users/[id]/password/route.ts @@ -0,0 +1,74 @@ +import bcrypt from "bcryptjs"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createAuditLog } from "@/lib/audit-log"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const passwordSchema = z.object({ + password: z.string().min(8).max(128) +}); + +type Context = { + params: { + id: string; + }; +}; + +export async function POST(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 Passwoerter neu setzen." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = passwordSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte ein Passwort mit mindestens 8 Zeichen angeben." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: params.id }, + select: { id: true, username: true, passwordHash: true } + }); + + if (!user) { + return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 }); + } + + const passwordHash = await bcrypt.hash(parsed.data.password, 12); + + await prisma.user.update({ + where: { id: params.id }, + data: { + passwordHash + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "user.passwordReset", + entityType: "user", + entityId: user.id, + entityLabel: user.username, + summary: "Ein Nutzerpasswort wurde neu gesetzt.", + metadata: { + rollback: { + kind: "user.passwordReset", + userId: user.id, + previousPasswordHash: user.passwordHash, + nextPasswordHash: passwordHash + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..2b35d95 --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; + +import { snapshotUser } 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"; + +type Context = { + params: { + id: string; + }; +}; + +export async function DELETE(_: 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 loeschen." }, { status: 403 }); + } + + if (viewer.id === params.id) { + return NextResponse.json({ error: "Du kannst dein eigenes Konto hier nicht loeschen." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: params.id }, + include: { + _count: { + select: { + approvals: true, + createdExpenses: true + } + } + } + }); + + if (!user) { + return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 }); + } + + if (user._count.approvals > 0 || user._count.createdExpenses > 0) { + return NextResponse.json( + { error: "Nutzer mit Freigaben oder Ausgaben koennen nicht geloescht werden." }, + { status: 400 } + ); + } + + if (user.role === "ADMIN") { + const adminCount = await prisma.user.count({ + where: { role: "ADMIN" } + }); + + if (adminCount <= 1) { + return NextResponse.json({ error: "Mindestens ein Admin muss erhalten bleiben." }, { status: 400 }); + } + } + + await prisma.user.delete({ + where: { id: params.id } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "user.delete", + entityType: "user", + entityId: user.id, + entityLabel: user.username, + summary: `Nutzer ${user.username} wurde geloescht.`, + metadata: { + rollback: { + kind: "user.delete", + deleted: snapshotUser(user) + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..90a48ef --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,118 @@ +import bcrypt from "bcryptjs"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotUser } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); +const approvalPreferenceSchema = z.enum(["CHAIR_A", "CHAIR_B", "FINANCE"]); + +const createUserSchema = z.object({ + username: z.string().trim().min(2).max(40), + password: z.string().min(8).max(128), + role: userRoleSchema, + workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), + approvalPreference: z.union([approvalPreferenceSchema, z.literal(""), z.null(), z.undefined()]) +}); + +export async function POST(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 Nutzer anlegen." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = createUserSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte Login-Name, Passwort und Rolle korrekt angeben." }, { status: 400 }); + } + + const username = parsed.data.username.toLowerCase(); + const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0 + ? parsed.data.workingGroupId + : null; + const requestedApprovalPreference = + parsed.data.approvalPreference === "CHAIR_A" || + parsed.data.approvalPreference === "CHAIR_B" || + parsed.data.approvalPreference === "FINANCE" + ? parsed.data.approvalPreference + : null; + + 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 }); + } + } + + const existingUserByUsername = await prisma.user.findUnique({ + where: { username } + }); + + if (existingUserByUsername) { + return NextResponse.json({ error: "Dieser Login-Name ist bereits vergeben." }, { status: 409 }); + } + + const passwordHash = await bcrypt.hash(parsed.data.password, 12); + const approvalPreference = + parsed.data.role === "FINANCE" + ? "FINANCE" + : parsed.data.role === "ADMIN" + ? requestedApprovalPreference + : null; + + const user = await prisma.user.create({ + data: { + name: username, + username, + email: null, + passwordHash, + role: parsed.data.role, + workingGroupId: parsed.data.role === "MEMBER" ? workingGroupId : null, + approvalPreference + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "user.create", + entityType: "user", + entityId: user.id, + entityLabel: user.username, + summary: `Nutzer ${user.username} wurde angelegt.`, + metadata: { + role: user.role, + workingGroupId: user.workingGroupId, + rollback: { + kind: "user.create", + created: snapshotUser(user) + } + } + }); + + return NextResponse.json({ + user: { + id: user.id, + name: user.name, + username: user.username, + role: user.role + } + }); +} diff --git a/src/app/api/working-groups/[id]/route.ts b/src/app/api/working-groups/[id]/route.ts new file mode 100644 index 0000000..84d2ad9 --- /dev/null +++ b/src/app/api/working-groups/[id]/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotWorkingGroup } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +type Context = { + params: { + id: string; + }; +}; + +const workingGroupSchema = z.object({ + name: z.string().trim().min(2).max(80) +}); + +export async function PATCH(request: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs bearbeiten." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = workingGroupSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 }); + } + + const workingGroup = await prisma.workingGroup.findUnique({ + where: { id: params.id } + }); + + if (!workingGroup) { + return NextResponse.json({ error: "AG nicht gefunden." }, { status: 404 }); + } + + const existingWorkingGroup = await prisma.workingGroup.findFirst({ + where: { + id: { + not: params.id + }, + name: { + equals: parsed.data.name, + mode: "insensitive" + } + } + }); + + if (existingWorkingGroup) { + return NextResponse.json({ error: "Diese AG gibt es bereits." }, { status: 409 }); + } + + const updatedWorkingGroup = await prisma.workingGroup.update({ + where: { + id: params.id + }, + data: { + name: parsed.data.name + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "workingGroup.update", + entityType: "workingGroup", + entityId: updatedWorkingGroup.id, + entityLabel: updatedWorkingGroup.name, + summary: `AG ${workingGroup.name} wurde auf ${updatedWorkingGroup.name} umbenannt.`, + metadata: { + rollback: { + kind: "workingGroup.update", + previous: snapshotWorkingGroup(workingGroup), + next: snapshotWorkingGroup(updatedWorkingGroup) + } + } + }); + + return NextResponse.json({ workingGroup: updatedWorkingGroup }); +} + +export async function DELETE(_: Request, { params }: Context) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs loeschen." }, { status: 403 }); + } + + const workingGroup = await prisma.workingGroup.findUnique({ + where: { id: params.id }, + include: { + _count: { + select: { + members: true, + budgets: true, + expenses: true + } + } + } + }); + + if (!workingGroup) { + return NextResponse.json({ error: "AG nicht gefunden." }, { status: 404 }); + } + + if (workingGroup._count.members > 0 || workingGroup._count.budgets > 0 || workingGroup._count.expenses > 0) { + return NextResponse.json( + { + error: + "Diese AG wird noch verwendet. Bitte zuerst Mitglieder, Budgets und eventuelle Ausgaben entfernen." + }, + { status: 400 } + ); + } + + await prisma.workingGroup.delete({ + where: { id: params.id } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "workingGroup.delete", + entityType: "workingGroup", + entityId: workingGroup.id, + entityLabel: workingGroup.name, + summary: `AG ${workingGroup.name} wurde geloescht.`, + metadata: { + rollback: { + kind: "workingGroup.delete", + deleted: snapshotWorkingGroup(workingGroup) + } + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/working-groups/route.ts b/src/app/api/working-groups/route.ts new file mode 100644 index 0000000..4242837 --- /dev/null +++ b/src/app/api/working-groups/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { snapshotWorkingGroup } from "@/lib/audit-snapshots"; +import { createAuditLog } from "@/lib/audit-log"; +import { canManageBudgets } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +const workingGroupSchema = z.object({ + name: z.string().trim().min(2).max(80) +}); + +export async function POST(request: Request) { + const viewer = await getCurrentViewer(); + + if (!viewer) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + if (!canManageBudgets(viewer.role)) { + return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen AGs verwalten." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const parsed = workingGroupSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Bitte einen gueltigen AG-Namen angeben." }, { status: 400 }); + } + + const existingWorkingGroup = await prisma.workingGroup.findFirst({ + where: { + name: { + equals: parsed.data.name, + mode: "insensitive" + } + } + }); + + if (existingWorkingGroup) { + return NextResponse.json({ error: "Diese AG gibt es bereits." }, { status: 409 }); + } + + const workingGroup = await prisma.workingGroup.create({ + data: { + name: parsed.data.name + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "workingGroup.create", + entityType: "workingGroup", + entityId: workingGroup.id, + entityLabel: workingGroup.name, + summary: `AG ${workingGroup.name} wurde angelegt.`, + metadata: { + rollback: { + kind: "workingGroup.create", + created: snapshotWorkingGroup(workingGroup) + } + } + }); + + return NextResponse.json({ workingGroup }); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..ad7bd54 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,44 @@ +:root { + color-scheme: light dark; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +html { + background: #f5f1e8; +} + +body { + background: + radial-gradient(circle at top left, rgba(242, 139, 75, 0.22), transparent 34%), + radial-gradient(circle at right 20%, rgba(59, 90, 224, 0.2), transparent 28%), + linear-gradient(180deg, #faf6ef 0%, #f3eee4 100%); + color: #17203a; +} + +@media (prefers-color-scheme: dark) { + html { + background: #0b1020; + } + + body { + background: + radial-gradient(circle at top left, rgba(255, 179, 107, 0.16), transparent 30%), + radial-gradient(circle at right 18%, rgba(140, 164, 255, 0.2), transparent 26%), + linear-gradient(180deg, #0b1020 0%, #111a2b 100%); + color: #e9eef9; + } +} + +a { + color: inherit; + text-decoration: none; +} + +* { + box-sizing: border-box; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..9ae1b41 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { Space_Grotesk, Source_Sans_3 } from "next/font/google"; +import type { ReactNode } from "react"; + +import { AppProviders } from "@/components/providers/app-providers"; +import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; + +import "./globals.css"; + +const displayFont = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-display" +}); + +const bodyFont = Source_Sans_3({ + subsets: ["latin"], + variable: "--font-body" +}); + +export const metadata: Metadata = { + title: "RFP Finanz\u00fcbersicht", + description: "Budgetkontrolle und Freigaben f\u00fcr Rave for Peace.", + applicationName: "RFP Finanz\u00fcbersicht", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/icon-192.png", type: "image/png", sizes: "192x192" }, + { url: "/icon-512.png", type: "image/png", sizes: "512x512" } + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], + shortcut: ["/favicon.ico"] + } +}; + +type RootLayoutProps = { + children: ReactNode; +}; + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + + {children} + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..28df0b5 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,34 @@ +import { Box, Container, Stack, Typography } from "@mui/material"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; + +import { LoginForm } from "@/components/login-form"; +import { authOptions } from "@/lib/auth"; + +export const dynamic = "force-dynamic"; + +export default async function LoginPage() { + const session = await getServerSession(authOptions); + + if (session?.user?.id) { + redirect("/"); + } + + return ( + + + + + + {"RFP Finanz\u00fcbersicht"} + + + {"Material-3-orientierter MVP f\u00fcr Budget\u00fcbersicht, Freigaben und Dokumentation im Verein."} + + + + + + + ); +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..61720fd --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "RFP Finanz\u00fcbersicht", + short_name: "RFP Finanzen", + description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.", + start_url: "/", + display: "standalone", + background_color: "#F5F1E8", + theme_color: "#3B5AE0", + icons: [ + { + src: "/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "maskable" + }, + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable" + }, + { + src: "/apple-touch-icon.png", + sizes: "180x180", + type: "image/png" + } + ] + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..cff0e4c --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,239 @@ +import { redirect } from "next/navigation"; + +import { DashboardShell } from "@/components/dashboard/dashboard-shell"; +import { getCurrentAccountingPeriod } from "@/lib/accounting-periods"; +import { getRollbackMetadata } from "@/lib/audit-log"; +import type { + DashboardAccountingPeriod, + DashboardAuditLog, + DashboardManagedUser, + DashboardViewer, + DashboardWorkingGroup +} from "@/lib/dashboard-types"; +import { canManageUsers } from "@/lib/domain"; +import prisma from "@/lib/prisma"; +import { getCurrentViewer } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const viewer = await getCurrentViewer(); + + if (!viewer) { + redirect("/login"); + } + + const currentPeriod = await getCurrentAccountingPeriod(); + + if (!currentPeriod) { + throw new Error("Kein Abrechnungszeitraum gefunden."); + } + + const accountingPeriods = await prisma.accountingPeriod.findMany({ + orderBy: { + startsAt: "desc" + } + }); + + const workingGroups = await prisma.workingGroup.findMany({ + include: { + members: { + select: { + id: true, + name: true, + username: true, + role: true + } + }, + budgets: { + where: { + periodId: currentPeriod.id + }, + orderBy: { + name: "asc" + }, + include: { + expenses: { + orderBy: { + createdAt: "desc" + }, + include: { + creator: { + select: { + id: true, + username: true + } + }, + approvals: { + orderBy: { + timestamp: "asc" + }, + include: { + user: { + select: { + id: true, + username: true + } + } + } + } + } + } + } + } + }, + orderBy: { + name: "asc" + } + }); + + const managedUsers = canManageUsers(viewer.role) + ? await prisma.user.findMany({ + include: { + workingGroup: { + select: { + name: true + } + }, + _count: { + select: { + approvals: true, + createdExpenses: true + } + } + }, + orderBy: [ + { + role: "asc" + }, + { + username: "asc" + } + ] + }) + : []; + + const auditLogs = canManageUsers(viewer.role) + ? await prisma.auditLog.findMany({ + orderBy: { + createdAt: "desc" + }, + take: 120, + include: { + actor: { + select: { + id: true, + name: true, + username: true, + role: true + } + } + } + }) + : []; + + const serializedViewer: DashboardViewer = { + id: viewer.id, + name: viewer.username, + username: viewer.username, + role: viewer.role, + workingGroupId: viewer.workingGroupId, + approvalPreference: viewer.approvalPreference + }; + + const serializedGroups: DashboardWorkingGroup[] = workingGroups.map((workingGroup) => ({ + id: workingGroup.id, + name: workingGroup.name, + totalBudget: workingGroup.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0), + members: workingGroup.members.map((member) => ({ + id: member.id, + name: member.username, + username: member.username, + role: member.role + })), + budgets: workingGroup.budgets.map((budget) => ({ + id: budget.id, + name: budget.name, + totalBudget: Number(budget.totalBudget), + colorCode: budget.colorCode, + periodId: budget.periodId, + expenses: budget.expenses.map((expense) => ({ + id: expense.id, + title: expense.title, + description: expense.description, + amount: Number(expense.amount), + budgetId: expense.budgetId, + periodId: expense.periodId, + approvalStatus: expense.approvalStatus, + recurrence: expense.recurrence, + paidAt: expense.paidAt?.toISOString() ?? null, + documentedAt: expense.documentedAt?.toISOString() ?? null, + proofUrl: expense.proofUrl, + createdAt: expense.createdAt.toISOString(), + creator: { + id: expense.creator.id, + name: expense.creator.username + }, + approvals: expense.approvals.map((approval) => ({ + id: approval.id, + approvalType: approval.approvalType, + timestamp: approval.timestamp.toISOString(), + user: { + id: approval.user.id, + name: approval.user.username + } + })) + })) + })) + })); + + const serializedUsers: DashboardManagedUser[] = managedUsers.map((user) => ({ + id: user.id, + name: user.username, + username: user.username, + role: user.role, + workingGroupId: user.workingGroupId, + workingGroupName: user.workingGroup?.name ?? null, + approvalPreference: user.approvalPreference, + createdExpensesCount: user._count.createdExpenses, + approvalsCount: user._count.approvals + })); + + const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({ + id: period.id, + name: period.name, + startsAt: period.startsAt.toISOString(), + endsAt: period.endsAt.toISOString(), + isCurrent: period.isCurrent + })); + + const serializedAuditLogs: DashboardAuditLog[] = auditLogs.map((entry) => ({ + id: entry.id, + action: entry.action, + entityType: entry.entityType, + entityId: entry.entityId, + entityLabel: entry.entityLabel, + summary: entry.summary, + canRestore: Boolean(getRollbackMetadata(entry.metadata)), + createdAt: entry.createdAt.toISOString(), + actor: entry.actor + ? { + id: entry.actor.id, + name: entry.actor.username, + username: entry.actor.username, + role: entry.actor.role + } + : null + })); + + return ( + + ); +} diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx new file mode 100644 index 0000000..a90dcdc --- /dev/null +++ b/src/components/dashboard/budget-column.tsx @@ -0,0 +1,743 @@ +"use client"; + +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import DoneAllRoundedIcon from "@mui/icons-material/DoneAllRounded"; +import EditRoundedIcon from "@mui/icons-material/EditRounded"; +import EuroRoundedIcon from "@mui/icons-material/EuroRounded"; +import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded"; +import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded"; +import { + Box, + Button, + Card, + CardContent, + Chip, + Divider, + IconButton, + Link, + Stack, + TextField, + Typography +} from "@mui/material"; +import { alpha, useTheme } from "@mui/material/styles"; +import { useEffect, useMemo, useState } from "react"; + +import { ColorPickerField } from "@/components/dashboard/color-picker-field"; +import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types"; +import { + APPROVAL_FLOW, + approvalLabel, + canDeleteExpense, + canDocumentExpense, + canManageBudgets, + canMarkPaid, + getAvailableApprovalTypes, + recurrenceLabel, + requiresManualApproval +} from "@/lib/domain"; + +type BudgetColumnProps = { + group: DashboardWorkingGroup; + viewer: DashboardViewer; + busy: boolean; + onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise; + onMarkPaid: (expenseId: string) => Promise; + onDocument: (expenseId: string, proofUrl?: string) => Promise; + onSaveWorkingGroup: (groupId: string, name: string) => Promise; + onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise; + onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise; + onDeleteBudget: (budgetId: string) => Promise; + onDeleteExpense: (expenseId: string) => Promise; +}; + +type BudgetDraft = { + name: string; + totalBudget: string; + colorCode: string; +}; + +const currencyFormatter = new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR" +}); + +const wrappingChipSx = { + height: "auto", + "& .MuiChip-label": { + display: "block", + whiteSpace: "normal", + py: 0.5 + } +} as const; + +function formatCurrency(value: number) { + return currencyFormatter.format(value); +} + +function createDraft(budget: DashboardBudget): BudgetDraft { + return { + name: budget.name, + totalBudget: budget.totalBudget.toFixed(2), + colorCode: budget.colorCode + }; +} + +function StatusChips({ expense }: { expense: DashboardExpense }) { + return ( + + + {expense.paidAt ? ( + } sx={wrappingChipSx} /> + ) : null} + {expense.documentedAt ? ( + } sx={wrappingChipSx} /> + ) : null} + {expense.recurrence === "MONTHLY" ? ( + + ) : null} + + ); +} + +function getApprovedSpend(expenses: DashboardExpense[]) { + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0), 0); +} + +function getPendingSpend(expenses: DashboardExpense[]) { + return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), 0); +} + +export function BudgetColumn({ + group, + viewer, + busy, + onApprove, + onMarkPaid, + onDocument, + onSaveWorkingGroup, + onDeleteWorkingGroup, + onSaveBudget, + onDeleteBudget, + onDeleteExpense +}: BudgetColumnProps) { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const [budgetDrafts, setBudgetDrafts] = useState>({}); + const [editingBudgetId, setEditingBudgetId] = useState(null); + const [isEditingGroup, setIsEditingGroup] = useState(false); + const [groupDraftName, setGroupDraftName] = useState(group.name); + const [proofUrlDrafts, setProofUrlDrafts] = useState>({}); + + const budgetCardWidth = 352; + const groupCardWidth = Math.min( + Math.max(group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * 16 + 48, 440), + 1160 + ); + const canEditBudgets = canManageBudgets(viewer.role); + + useEffect(() => { + setBudgetDrafts( + Object.fromEntries(group.budgets.map((budget) => [budget.id, createDraft(budget)])) + ); + }, [group.budgets]); + + useEffect(() => { + if (editingBudgetId && !group.budgets.some((budget) => budget.id === editingBudgetId)) { + setEditingBudgetId(null); + } + }, [editingBudgetId, group.budgets]); + + useEffect(() => { + setGroupDraftName(group.name); + }, [group.name]); + + const approvedSpend = useMemo( + () => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0), + [group.budgets] + ); + const pendingSpend = useMemo( + () => group.budgets.reduce((sum, budget) => sum + getPendingSpend(budget.expenses), 0), + [group.budgets] + ); + const totalCommitted = approvedSpend + pendingSpend; + const remainingBudget = group.totalBudget - totalCommitted; + + function getDraft(budget: DashboardBudget) { + return budgetDrafts[budget.id] ?? createDraft(budget); + } + + function updateDraft(budget: DashboardBudget, patch: Partial) { + setBudgetDrafts((current) => ({ + ...current, + [budget.id]: { + ...getDraft(budget), + ...patch + } + })); + } + + function resetDraft(budget: DashboardBudget) { + setBudgetDrafts((current) => ({ + ...current, + [budget.id]: createDraft(budget) + })); + } + + return ( + + + + + + + {group.name} + + + Gesamtbudgets: {formatCurrency(group.totalBudget)} + + + + {totalCommitted > group.totalBudget ? : null} + {canEditBudgets ? ( + { + if (isEditingGroup) { + setGroupDraftName(group.name); + setIsEditingGroup(false); + return; + } + + setIsEditingGroup(true); + }} + sx={{ + border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`, + bgcolor: alpha(theme.palette.background.default, isDark ? 0.72 : 0.65) + }} + > + {isEditingGroup ? : } + + ) : null} + + + + {isEditingGroup ? ( + { + event.preventDefault(); + await onSaveWorkingGroup(group.id, groupDraftName); + setIsEditingGroup(false); + }} + sx={{ + p: 2, + borderRadius: "18px", + border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, + background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.86 : 0.94)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.05 : 0.02)} 100%)` + }} + > + + setGroupDraftName(event.target.value)} + fullWidth + /> + + AGs lassen sich nur loeschen, wenn keine Mitglieder, Budgets oder Ausgaben mehr daran haengen. + + + + + + + + + ) : null} + + + } + label={`Freigegeben: ${formatCurrency(approvedSpend)}`} + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + } + label={`Geplant: ${formatCurrency(pendingSpend)}`} + variant="outlined" + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + } + label={`Rest: ${formatCurrency(remainingBudget)}`} + color={remainingBudget < 0 ? "error" : "default"} + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + + + {group.budgets.length === 0 ? ( + + In dieser AG gibt es noch keine Budgets. + + ) : null} + + + {group.budgets.map((budget) => { + const draft = getDraft(budget); + const isEditing = editingBudgetId === budget.id; + const budgetApproved = getApprovedSpend(budget.expenses); + const budgetPending = getPendingSpend(budget.expenses); + const budgetCommitted = budgetApproved + budgetPending; + const budgetRemaining = budget.totalBudget - budgetCommitted; + const approvedPercent = budget.totalBudget > 0 ? Math.min((budgetApproved / budget.totalBudget) * 100, 100) : 0; + const cumulativePercent = + budget.totalBudget > 0 ? Math.min((budgetCommitted / budget.totalBudget) * 100, 100) : 0; + + return ( + + + + + + + + + {budget.name} + + + Budget: {formatCurrency(budget.totalBudget)} + + {canEditBudgets ? ( + { + if (isEditing) { + resetDraft(budget); + setEditingBudgetId(null); + return; + } + + setEditingBudgetId(budget.id); + }} + sx={{ + border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`, + bgcolor: alpha(theme.palette.background.default, isDark ? 0.72 : 0.65) + }} + > + {isEditing ? : } + + ) : null} + + + + + + + + + + + + } + label={`Freigegeben: ${formatCurrency(budgetApproved)}`} + sx={{ ...wrappingChipSx, width: "fit-content", bgcolor: alpha(budget.colorCode, 0.14) }} + /> + } + label={`Geplant: ${formatCurrency(budgetPending)}`} + variant="outlined" + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + } + label={`Rest: ${formatCurrency(budgetRemaining)}`} + color={budgetRemaining < 0 ? "error" : "default"} + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + + {"Unter 50 EUR werden sofort freigegeben. Gr\u00f6\u00dfere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen."} + + + + + {isEditing ? ( + { + event.preventDefault(); + await onSaveBudget(budget.id, draft.name, draft.totalBudget, draft.colorCode); + setEditingBudgetId(null); + }} + > + + updateDraft(budget, { name: event.target.value })} + fullWidth + /> + updateDraft(budget, { totalBudget: event.target.value })} + fullWidth + /> + updateDraft(budget, { colorCode: value })} + /> + + + + + + + + ) : null} + + + + + {budget.expenses.length === 0 ? ( + + + Noch keine Ausgaben in diesem Budget. + + + ) : null} + + {budget.expenses.map((expense) => { + const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType); + const availableApprovals = requiresManualApproval(expense.amount) + ? getAvailableApprovalTypes(viewer.role, viewer.approvalPreference, doneApprovalTypes) + : []; + + return ( + + + + + + {expense.title} + + + {formatCurrency(expense.amount)} von {expense.creator.name} + + + + + + {expense.description ? ( + + {expense.description} + + ) : null} + + {requiresManualApproval(expense.amount) ? ( + + {APPROVAL_FLOW.map((approvalType) => { + const matchingApproval = expense.approvals.find( + (approval) => approval.approvalType === approvalType + ); + + return ( + + ); + })} + + ) : null} + + {expense.proofUrl ? ( + + {"Beleg \u00f6ffnen"} + + ) : null} + + + {availableApprovals.map((approvalType) => ( + + ))} + + {!expense.paidAt && expense.approvalStatus === "APPROVED" && canMarkPaid(viewer.role) ? ( + + ) : null} + + {canDeleteExpense( + viewer.role, + viewer.id, + expense.creator.id, + expense.approvalStatus, + expense.paidAt, + expense.documentedAt + ) ? ( + + ) : null} + + + {expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? ( + + + setProofUrlDrafts((current) => ({ + ...current, + [expense.id]: event.target.value + })) + } + size="small" + fullWidth + /> + + + ) : null} + + + Angelegt am{" "} + {new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format( + new Date(expense.createdAt) + )} + + + + ); + })} + + + + + ); + })} + + + + + ); +} diff --git a/src/components/dashboard/color-picker-field.tsx b/src/components/dashboard/color-picker-field.tsx new file mode 100644 index 0000000..55d83b5 --- /dev/null +++ b/src/components/dashboard/color-picker-field.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Box, Stack, Typography } from "@mui/material"; +import { alpha, useTheme } from "@mui/material/styles"; + +import { COLOR_PRESETS } from "@/lib/domain"; + +type ColorPickerFieldProps = { + label: string; + value: string; + onChange: (value: string) => void; +}; + +export function ColorPickerField({ label, value, onChange }: ColorPickerFieldProps) { + const theme = useTheme(); + + return ( + + + {label} + + + {COLOR_PRESETS.map((preset) => ( + onChange(preset)} + sx={{ + width: 28, + height: 28, + borderRadius: "50%", + border: + value === preset + ? `3px solid ${theme.palette.text.primary}` + : `2px solid ${alpha(theme.palette.text.primary, 0.14)}`, + bgcolor: preset, + cursor: "pointer" + }} + /> + ))} + + + ); +} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx new file mode 100644 index 0000000..45dda9e --- /dev/null +++ b/src/components/dashboard/dashboard-shell.tsx @@ -0,0 +1,1835 @@ +"use client"; + +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; +import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; +import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; +import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded"; +import WalletRoundedIcon from "@mui/icons-material/WalletRounded"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Container, + MenuItem, + Stack, + Tab, + Tabs, + TextField, + Typography, + useMediaQuery +} from "@mui/material"; +import { alpha, useTheme } from "@mui/material/styles"; +import { signOut } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import type { FormEvent } from "react"; +import { startTransition, useEffect, useMemo, useState } from "react"; + +import { BudgetColumn } from "@/components/dashboard/budget-column"; +import { ColorPickerField } from "@/components/dashboard/color-picker-field"; +import type { + DashboardAccountingPeriod, + DashboardAuditLog, + DashboardManagedUser, + DashboardViewer, + DashboardWorkingGroup +} from "@/lib/dashboard-types"; +import { + AUTO_APPROVAL_THRESHOLD, + canManageBudgets, + canManageUsers, + roleLabel +} from "@/lib/domain"; + +type DashboardShellProps = { + viewer: DashboardViewer; + workingGroups: DashboardWorkingGroup[]; + managedUsers: DashboardManagedUser[]; + auditLogs: DashboardAuditLog[]; + accountingPeriods: DashboardAccountingPeriod[]; + currentPeriodId: string; +}; + +type ExpenseFormState = { + title: string; + description: string; + amount: string; + agId: string; + budgetId: string; + recurrence: "NONE" | "MONTHLY"; + proofUrl: string; +}; + +type BudgetFormState = { + workingGroupId: string; + name: string; + totalBudget: string; + colorCode: string; +}; + +type WorkingGroupFormState = { + name: string; +}; + +type UserFormState = { + username: string; + password: string; + role: "ADMIN" | "FINANCE" | "MEMBER"; + workingGroupId: string; + approvalPreference: "" | "CHAIR_A" | "CHAIR_B"; +}; + +type PeriodFormState = { + name: string; + startsAt: string; + endsAt: string; + copyBudgetsFromPeriodId: string; +}; + +type DashboardMessage = { + type: "success" | "error"; + text: string; +}; + +type MobileSection = "overview" | "actions"; +type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs"; + +const currencyFormatter = new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR" +}); + +const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short" +}); + +function toDateInputValue(value: string) { + return value.slice(0, 10); +} + +function formatPeriodRange(startsAt: string, endsAt: string) { + const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); + return `${formatter.format(new Date(startsAt))} bis ${formatter.format(new Date(endsAt))}`; +} + +function getSuggestedPeriodDraft(currentPeriod: DashboardAccountingPeriod | undefined): PeriodFormState { + if (!currentPeriod) { + const year = new Date().getFullYear(); + return { + name: `Haushalt ${year}`, + startsAt: toDateInputValue(new Date(Date.UTC(year, 0, 1)).toISOString()), + endsAt: toDateInputValue(new Date(Date.UTC(year, 11, 31)).toISOString()), + copyBudgetsFromPeriodId: "" + }; + } + + const startsAt = new Date(currentPeriod.startsAt); + const endsAt = new Date(currentPeriod.endsAt); + const duration = Math.max(endsAt.getTime() - startsAt.getTime(), 24 * 60 * 60 * 1000); + const nextStart = new Date(endsAt.getTime() + 24 * 60 * 60 * 1000); + const nextEnd = new Date(nextStart.getTime() + duration); + + return { + name: `${currentPeriod.name} Folgezeitraum`, + startsAt: toDateInputValue(nextStart.toISOString()), + endsAt: toDateInputValue(nextEnd.toISOString()), + copyBudgetsFromPeriodId: currentPeriod.id + }; +} + +function generatePassword(length = 14) { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%"; + const cryptoSource = globalThis.crypto; + + if (!cryptoSource?.getRandomValues) { + return "RFP2026!Start"; + } + + const values = cryptoSource.getRandomValues(new Uint32Array(length)); + return Array.from(values, (value) => alphabet[value % alphabet.length]).join(""); +} + +async function parseResponse(response: Response) { + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(payload?.error ?? "Die Anfrage konnte nicht verarbeitet werden."); + } + + return payload; +} + +export function DashboardShell({ + viewer, + workingGroups, + managedUsers, + auditLogs, + accountingPeriods, + currentPeriodId +}: DashboardShellProps) { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg")); + const router = useRouter(); + + const visibleGroups = workingGroups; + const editableExpenseGroups = + viewer.role === "MEMBER" + ? workingGroups.filter((group) => group.id === viewer.workingGroupId) + : workingGroups; + const canManageAccounts = canManageUsers(viewer.role); + const canManagePeriods = canManageBudgets(viewer.role); + const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0]; + const desktopSections = [ + { value: "overview" as const, label: "\u00dcbersicht" }, + ...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []), + ...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []), + ...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []), + ...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : []) + ]; + const showDesktopSectionTabs = !isCompactLayout && desktopSections.length > 1; + + const defaultEditableGroup = + editableExpenseGroups.find((group) => group.id === viewer.workingGroupId) ?? + editableExpenseGroups[0] ?? + visibleGroups[0]; + const defaultBudget = defaultEditableGroup?.budgets[0]; + + const [expenseForm, setExpenseForm] = useState({ + title: "", + description: "", + amount: "", + agId: defaultEditableGroup?.id ?? "", + budgetId: defaultBudget?.id ?? "", + recurrence: "NONE", + proofUrl: "" + }); + const [budgetForm, setBudgetForm] = useState({ + workingGroupId: visibleGroups[0]?.id ?? "", + name: "Hauptbudget", + totalBudget: "1200", + colorCode: "#FFB94A" + }); + const [workingGroupForm, setWorkingGroupForm] = useState({ + name: "" + }); + const [userForm, setUserForm] = useState({ + username: "", + password: "", + role: "MEMBER", + workingGroupId: visibleGroups[0]?.id ?? "", + approvalPreference: "" + }); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + const [mobileSection, setMobileSection] = useState("overview"); + const [desktopSection, setDesktopSection] = useState("overview"); + const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); + const [selectedMobileGroupId, setSelectedMobileGroupId] = useState( + viewer.workingGroupId ?? visibleGroups[0]?.id ?? "" + ); + const [backupFile, setBackupFile] = useState(null); + const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); + const [passwordDrafts, setPasswordDrafts] = useState>({}); + const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); + + useEffect(() => { + if (visibleGroups.length === 0) { + setSelectedMobileGroupId(""); + return; + } + + const hasSelectedGroup = visibleGroups.some((group) => group.id === selectedMobileGroupId); + if (!hasSelectedGroup) { + setSelectedMobileGroupId(viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""); + } + }, [selectedMobileGroupId, viewer.workingGroupId, visibleGroups]); + + useEffect(() => { + setSelectedCurrentPeriodId(currentPeriodId); + setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); + }, [currentPeriod, currentPeriodId]); + + useEffect(() => { + if (!desktopSections.some((section) => section.value === desktopSection)) { + setDesktopSection("overview"); + } + }, [desktopSection, desktopSections]); + + useEffect(() => { + if (visibleGroups.length === 0) { + setBudgetForm((current) => ({ + ...current, + workingGroupId: "" + })); + return; + } + + const groupStillExists = visibleGroups.some((group) => group.id === budgetForm.workingGroupId); + + if (!groupStillExists) { + setBudgetForm((current) => ({ + ...current, + workingGroupId: visibleGroups[0]?.id ?? "" + })); + } + }, [budgetForm.workingGroupId, visibleGroups]); + + useEffect(() => { + if (!message || message.type !== "success") { + return; + } + + const timeoutId = window.setTimeout(() => { + setMessage((current) => (current?.type === "success" ? null : current)); + }, 10000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [message]); + + useEffect(() => { + const hasEditableGroup = editableExpenseGroups.some((group) => group.id === expenseForm.agId); + + if (!hasEditableGroup) { + setExpenseForm((current) => ({ + ...current, + agId: defaultEditableGroup?.id ?? "", + budgetId: defaultEditableGroup?.budgets[0]?.id ?? "" + })); + } + }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId]); + + useEffect(() => { + const selectedGroup = + editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; + const hasBudget = selectedGroup?.budgets.some((budget) => budget.id === expenseForm.budgetId) ?? false; + + if (!hasBudget) { + setExpenseForm((current) => ({ + ...current, + budgetId: selectedGroup?.budgets[0]?.id ?? "" + })); + } + }, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]); + + useEffect(() => { + if (userForm.role !== "MEMBER") { + return; + } + + const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId); + + if (!groupStillExists) { + setUserForm((current) => ({ + ...current, + workingGroupId: visibleGroups[0]?.id ?? "" + })); + } + }, [userForm.role, userForm.workingGroupId, visibleGroups]); + + const selectedExpenseGroup = + editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup; + const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? []; + const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0]; + const selectedBudgetWorkingGroup = + visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null; + const selectedPeriodForManagement = + accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; + + const totals = useMemo(() => { + return visibleGroups.reduce( + (summary, group) => { + const approved = group.budgets.reduce( + (groupSum, budget) => + groupSum + + budget.expenses.reduce( + (sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.amount : 0), + 0 + ), + 0 + ); + const pending = group.budgets.reduce( + (groupSum, budget) => + groupSum + + budget.expenses.reduce( + (sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), + 0 + ), + 0 + ); + const subscriptions = group.budgets.reduce( + (groupSum, budget) => + groupSum + + budget.expenses.reduce( + (sum, expense) => sum + (expense.recurrence === "MONTHLY" ? expense.amount : 0), + 0 + ), + 0 + ); + const subscriptionCount = group.budgets.reduce( + (groupSum, budget) => + groupSum + budget.expenses.filter((expense) => expense.recurrence === "MONTHLY").length, + 0 + ); + + summary.budget += group.totalBudget; + summary.approved += approved; + summary.pending += pending; + summary.subscriptions += subscriptions; + summary.subscriptionCount += subscriptionCount; + + return summary; + }, + { budget: 0, approved: 0, pending: 0, subscriptions: 0, subscriptionCount: 0 } + ); + }, [visibleGroups]); + + async function runAction( + task: () => Promise, + successMessage: string | ((result: T) => string) + ) { + setBusy(true); + setMessage(null); + + try { + const result = await task(); + setMessage({ + type: "success", + text: typeof successMessage === "function" ? successMessage(result) : successMessage + }); + startTransition(() => { + router.refresh(); + }); + } catch (error) { + const text = error instanceof Error ? error.message : "Unerwarteter Fehler."; + setMessage({ type: "error", text }); + } finally { + setBusy(false); + } + } + + async function handleCreateExpense(event: FormEvent) { + event.preventDefault(); + + if (!expenseForm.agId) { + setMessage({ type: "error", text: "Bitte zuerst eine bearbeitbare AG ausw\u00e4hlen." }); + return; + } + + if (!expenseForm.budgetId) { + setMessage({ type: "error", text: "Bitte zuerst ein Budget f\u00fcr diese AG anlegen oder ausw\u00e4hlen." }); + return; + } + + await runAction(async () => { + await parseResponse( + await fetch("/api/expenses", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + title: expenseForm.title, + description: expenseForm.description, + amount: expenseForm.amount, + agId: expenseForm.agId, + budgetId: expenseForm.budgetId, + recurrence: expenseForm.recurrence, + proofUrl: expenseForm.proofUrl + }) + }) + ); + + const resetGroup = defaultEditableGroup?.id ?? ""; + const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; + + setExpenseForm({ + title: "", + description: "", + amount: "", + agId: resetGroup, + budgetId: resetBudget, + recurrence: "NONE", + proofUrl: "" + }); + }, "Ausgabe wurde gespeichert."); + } + + async function handleUpsertBudget(event: FormEvent) { + event.preventDefault(); + + if (!budgetForm.workingGroupId) { + setMessage({ + type: "error", + text: "Bitte zuerst eine AG auswaehlen oder neu anlegen." + }); + return; + } + + await runAction(async () => { + await parseResponse( + await fetch("/api/budgets", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + ...budgetForm, + periodId: currentPeriodId + }) + }) + ); + }, "Budget wurde gespeichert."); + } + + async function handleCreateWorkingGroup(event: FormEvent) { + event.preventDefault(); + + await runAction( + async () => { + const result = (await parseResponse( + await fetch("/api/working-groups", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(workingGroupForm) + }) + )) as { workingGroup?: { id: string; name: string } }; + + setWorkingGroupForm({ name: "" }); + + if (result.workingGroup?.id) { + setBudgetForm((current) => ({ + ...current, + workingGroupId: result.workingGroup?.id ?? current.workingGroupId + })); + } + + return result; + }, + (result) => `AG ${result.workingGroup?.name ?? "wurde"} wurde angelegt.` + ); + } + + async function handleApprove(expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") { + await runAction(async () => { + await parseResponse( + await fetch(`/api/expenses/${expenseId}/approve`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ approvalType }) + }) + ); + }, `Freigabe ${approvalType} wurde erfasst.`); + } + + async function handleMarkPaid(expenseId: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/expenses/${expenseId}/paid`, { + method: "POST" + }) + ); + }, "Ausgabe ist jetzt als bezahlt markiert."); + } + + async function handleDocument(expenseId: string, proofUrl?: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/expenses/${expenseId}/documented`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ proofUrl }) + }) + ); + }, "Ausgabe wurde dokumentiert."); + } + + async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/budgets/${budgetId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name, + totalBudget, + colorCode + }) + }) + ); + }, "Budget wurde aktualisiert."); + } + + async function handleDeleteBudget(budgetId: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/budgets/${budgetId}`, { + method: "DELETE" + }) + ); + }, "Budget wurde gel\u00f6scht."); + } + + async function handleDeleteExpense(expenseId: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/expenses/${expenseId}`, { + method: "DELETE" + }) + ); + }, "Ausgabe wurde gel\u00f6scht."); + } + + async function handleCreatePeriod(event: FormEvent) { + event.preventDefault(); + + await runAction(async () => { + await parseResponse( + await fetch("/api/periods", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(periodForm) + }) + ); + }, "Neuer Abrechnungszeitraum wurde angelegt."); + } + + async function handleDeletePeriod(periodId: string, periodName: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/periods/${periodId}`, { + method: "DELETE" + }) + ); + }, `Zeitraum ${periodName} wurde geloescht.`); + } + + async function handleSetCurrentPeriod() { + await runAction(async () => { + await parseResponse( + await fetch("/api/periods/current", { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + periodId: selectedCurrentPeriodId + }) + }) + ); + }, "Aktuelle \u00dcbersicht wurde auf den gew\u00e4hlten Zeitraum umgestellt."); + } + + async function handleSaveWorkingGroup(groupId: string, name: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/working-groups/${groupId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ name }) + }) + ); + }, "AG wurde aktualisiert."); + } + + async function handleDeleteWorkingGroup(groupId: string, groupName: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/working-groups/${groupId}`, { + method: "DELETE" + }) + ); + + const nextGroup = visibleGroups.find((group) => group.id !== groupId) ?? null; + + setBudgetForm((current) => ({ + ...current, + workingGroupId: nextGroup?.id ?? "" + })); + + setExpenseForm((current) => ({ + ...current, + agId: current.agId === groupId ? nextGroup?.id ?? "" : current.agId, + budgetId: current.agId === groupId ? nextGroup?.budgets[0]?.id ?? "" : current.budgetId + })); + }, `AG ${groupName} wurde geloescht.`); + } + + async function handleCreateUser(event: FormEvent) { + event.preventDefault(); + + await runAction( + async () => { + const createdPassword = userForm.password; + const createdUsername = userForm.username.trim().toLowerCase(); + + await parseResponse( + await fetch("/api/users", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: createdUsername, + password: userForm.password, + role: userForm.role, + workingGroupId: userForm.role === "MEMBER" ? userForm.workingGroupId : "", + approvalPreference: userForm.role === "ADMIN" ? userForm.approvalPreference : "" + }) + }) + ); + + setUserForm({ + username: "", + password: "", + role: "MEMBER", + workingGroupId: visibleGroups[0]?.id ?? "", + approvalPreference: "" + }); + + return { + createdUsername, + createdPassword + }; + }, + ({ createdUsername, createdPassword }) => + `Nutzer wurde angelegt. Startpasswort f\u00fcr ${createdUsername}: ${createdPassword}` + ); + } + + async function handleDeleteUser(userId: string) { + await runAction(async () => { + await parseResponse( + await fetch(`/api/users/${userId}`, { + method: "DELETE" + }) + ); + }, "Nutzer wurde gel\u00f6scht."); + } + + async function handleResetPassword(userId: string, userName: string) { + const nextPassword = passwordDrafts[userId]?.trim() ?? ""; + + if (nextPassword.length < 8) { + setMessage({ + type: "error", + text: "Bitte ein neues Passwort mit mindestens 8 Zeichen eingeben." + }); + return; + } + + await runAction( + async () => { + await parseResponse( + await fetch(`/api/users/${userId}/password`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + password: nextPassword + }) + }) + ); + + setEditingPasswordUserId(null); + + return { + userName, + nextPassword + }; + }, + ({ userName: changedUserName, nextPassword: changedPassword }) => + `Neues Passwort f\u00fcr ${changedUserName}: ${changedPassword}` + ); + } + + async function handleImportBackup() { + if (!backupFile) { + setMessage({ + type: "error", + text: "Bitte zuerst eine CSV-Datei auswählen." + }); + return; + } + + await runAction( + async () => { + const formData = new FormData(); + formData.set("file", backupFile); + + const result = await parseResponse( + await fetch("/api/import/csv", { + method: "POST", + body: formData + }) + ); + + setBackupFile(null); + return result as { importedRows?: number }; + }, + (result) => + `Backup wurde eingespielt.${typeof result.importedRows === "number" ? ` Importierte Zeilen: ${result.importedRows}.` : ""}` + ); + } + + async function handleRestoreAuditLog(entryId: string, summary: string) { + if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) { + return; + } + + await runAction(async () => { + await parseResponse( + await fetch(`/api/audit-logs/${entryId}/restore`, { + method: "POST" + }) + ); + }, "Änderung wurde zurückgesetzt."); + } + + function openPasswordReset(userId: string) { + setEditingPasswordUserId(userId); + setPasswordDrafts((current) => ({ + ...current, + [userId]: current[userId] && current[userId].length >= 8 ? current[userId] : generatePassword() + })); + } + + const islandCardSx = { + borderRadius: { xs: "24px", md: "30px" }, + border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, + background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.82 : 0.96)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.04 : 0.02)} 100%)`, + overflow: "hidden", + boxShadow: isDark ? `0 20px 48px ${alpha("#02040A", 0.34)}` : `0 18px 44px ${alpha("#B86E2B", 0.08)}` + }; + + const nestedPanelSx = { + p: 2, + borderRadius: "20px", + border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`, + background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.78 : 0.9)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.05 : 0.02)} 100%)`, + overflow: "hidden" + }; + + const periodManagementPanel = canManagePeriods ? ( + + + + Zeitraum wechseln + + + {"Nur Vorstand und Finanz-AG k\u00f6nnen die aktuelle \u00dcbersicht global umstellen."} + + + + setSelectedCurrentPeriodId(event.target.value)} + fullWidth + sx={{ minWidth: 0 }} + > + {accountingPeriods.map((period) => ( + + {period.name} + + ))} + + + + + + {selectedPeriodForManagement?.isCurrent + ? "Der aktuell aktive Zeitraum kann nicht gel\u00f6scht werden." + : "Leere, nicht aktive Zeitr\u00e4ume lassen sich hier wieder entfernen."} + + + + + + Neuen Zeitraum anlegen + + setPeriodForm((current) => ({ ...current, name: event.target.value }))} + required + fullWidth + /> + + setPeriodForm((current) => ({ ...current, startsAt: event.target.value }))} + InputLabelProps={{ shrink: true }} + required + fullWidth + /> + setPeriodForm((current) => ({ ...current, endsAt: event.target.value }))} + InputLabelProps={{ shrink: true }} + required + fullWidth + /> + + + setPeriodForm((current) => ({ + ...current, + copyBudgetsFromPeriodId: event.target.value + })) + } + fullWidth + helperText={"Optional kopiert die vorhandenen Budgett\u00f6pfe direkt in den neuen Zeitraum."} + > + {`Ohne Budget\u00fcbernahme`} + {accountingPeriods.map((period) => ( + + {period.name} + + ))} + + + + + + ) : null; + + const actionCards = ( + + {isCompactLayout || desktopSection === "overview" ? ( + + + + + + Neue Ausgabe + + + {"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "} + {AUTO_APPROVAL_THRESHOLD} + {" EUR wird automatisch freigegeben."} + + + + + + setExpenseForm((current) => ({ ...current, title: event.target.value }))} + required + fullWidth + /> + + setExpenseForm((current) => ({ ...current, description: event.target.value })) + } + fullWidth + multiline + minRows={3} + /> + setExpenseForm((current) => ({ ...current, amount: event.target.value }))} + required + fullWidth + /> + + setExpenseForm((current) => ({ + ...current, + recurrence: event.target.value as ExpenseFormState["recurrence"] + })) + } + fullWidth + helperText={"Monatliche Abos erscheinen oben gesammelt im \u00dcberblick."} + > + Einmalig + Monatliches Abo + + setExpenseForm((current) => ({ ...current, agId: event.target.value }))} + required + fullWidth + disabled={viewer.role === "MEMBER" || editableExpenseGroups.length === 0} + helperText={ + viewer.role === "MEMBER" + ? "Du kannst nur in deiner eigenen AG buchen." + : "W\u00e4hle die AG, in der die Ausgabe erfasst werden soll." + } + > + {editableExpenseGroups.map((group) => ( + + {group.name} + + ))} + + setExpenseForm((current) => ({ ...current, budgetId: event.target.value }))} + required + fullWidth + disabled={selectedBudgetOptions.length === 0} + helperText={ + selectedBudgetOptions.length === 0 + ? "In dieser AG gibt es noch kein Budget." + : "Bitte den passenden Budgettopf w\u00e4hlen." + } + > + {selectedBudgetOptions.map((budget) => ( + + {budget.name} + + ))} + + setExpenseForm((current) => ({ ...current, proofUrl: event.target.value }))} + fullWidth + /> + + + + + + + ) : null} + + {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( + + + + + + AG anlegen + + + {"Lege Arbeitsgruppen separat an. Bearbeiten oder loeschen geht danach direkt in der Uebersicht per Stift."} + + + + + + setWorkingGroupForm((current) => ({ ...current, name: event.target.value })) + } + required + fullWidth + /> + + + + + + + ) : null} + + {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( + + + + + + Budget anlegen + + + {"Neue Budgett\u00f6pfe werden immer f\u00fcr den aktuell ausgew\u00e4hlten Abrechnungszeitraum angelegt."} + + + {currentPeriod ? ( + + ) : null} + + + + setBudgetForm((current) => ({ ...current, workingGroupId: event.target.value })) + } + required + fullWidth + disabled={visibleGroups.length === 0} + helperText={ + visibleGroups.length === 0 + ? "Lege zuerst eine AG an." + : "Das Budget wird in der ausgewaehlten AG angelegt." + } + > + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + + {selectedBudgetWorkingGroup + ? `Neue Budgettoepfe landen in ${selectedBudgetWorkingGroup.name}.` + : "Waehle zuerst eine bestehende AG aus."} + + setBudgetForm((current) => ({ ...current, name: event.target.value }))} + required + /> + + setBudgetForm((current) => ({ ...current, totalBudget: event.target.value })) + } + required + /> + setBudgetForm((current) => ({ ...current, colorCode: value }))} + /> + + + + + + + + + ) : null} + + {canManagePeriods && isCompactLayout ? ( + + {periodManagementPanel} + + ) : null} + + {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( + + + + + + CSV-Backup + + + {"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."} + + + + + + + + + {backupFile + ? `Ausgewählt: ${backupFile.name}` + : "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."} + + + + + ) : null} + + {canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( + + + + + + Nutzer anlegen + + + {"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."} + + + + + setUserForm((current) => ({ ...current, username: event.target.value }))} + required + /> + + + setUserForm((current) => ({ ...current, password: event.target.value })) + } + required + fullWidth + helperText={"Dieses Passwort wird nach dem Anlegen oben als Best\u00e4tigung angezeigt."} + /> + + + + setUserForm((current) => ({ + ...current, + role: event.target.value as UserFormState["role"], + approvalPreference: event.target.value === "ADMIN" ? current.approvalPreference : "", + workingGroupId: event.target.value === "MEMBER" ? current.workingGroupId : "" + })) + } + required + > + Vorstand + Finanz-AG + AG-Mitglied + + + {userForm.role === "MEMBER" ? ( + + setUserForm((current) => ({ ...current, workingGroupId: event.target.value })) + } + required + > + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + ) : null} + + {userForm.role === "ADMIN" ? ( + + setUserForm((current) => ({ + ...current, + approvalPreference: event.target.value as UserFormState["approvalPreference"] + })) + } + > + Keine Voreinstellung + Vorstand A + Vorstand B + + ) : null} + + + + + + + + ) : null} + + {canManageAccounts && (isCompactLayout || desktopSection === "users") ? ( + + + + + + Nutzer verwalten + + + {"Bestehende Passw\u00f6rter bleiben sicher gehasht und sind nicht auslesbar. Hier kannst du neue setzen und direkt sehen."} + + + + {managedUsers.map((user) => { + const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0; + const isResetOpen = editingPasswordUserId === user.id; + + return ( + + + + + {user.username} + + {roleLabel(user.role)} + + + + + + + + + + + + + + {isResetOpen ? ( + + + + setPasswordDrafts((current) => ({ + ...current, + [user.id]: event.target.value + })) + } + fullWidth + helperText={ + "Nur neu gesetzte Passw\u00f6rter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen." + } + /> + + + + + + + + ) : null} + + + ); + })} + + + + + ) : null} + + {canManagePeriods && isCompactLayout ? ( + + {periodManagementPanel} + + ) : null} + + {canManageAccounts && (isCompactLayout || desktopSection === "logs") ? ( + + + + + + {"\u00c4nderungsverlauf"} + + + {"Zeigt die letzten \u00c4nderungen an Nutzern, Ausgaben, Budgets, AGs und Zeitr\u00e4umen."} + + + {auditLogs.length > 0 ? ( + + {auditLogs.map((entry) => ( + + + + {entry.summary} + + + + + {entry.entityLabel ? : null} + + + + {entry.actor + ? `${entry.actor.username} - ${roleLabel(entry.actor.role)}` + : "Systemeintrag"} + + {entry.canRestore ? ( + + ) : null} + + + ))} + + ) : ( + {"Noch keine \u00c4nderungen protokolliert."} + )} + + + + ) : null} + + ); + + const periodOverviewCard = isCompactLayout ? null : showDesktopSectionTabs ? ( + + + setDesktopSection(nextValue)} + variant="fullWidth" + sx={{ + minHeight: 0, + ".MuiTabs-indicator": { + display: "none" + }, + ".MuiTab-root": { + minHeight: 48, + px: 1.5, + py: 1, + m: 0.35, + borderRadius: "16px", + textTransform: "none", + fontWeight: 700, + border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.1)}`, + color: theme.palette.text.secondary, + backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.42 : 0.9) + }, + ".Mui-selected": { + color: theme.palette.primary.main, + borderColor: alpha(theme.palette.primary.main, 0.35), + backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.22 : 0.12) + } + }} + > + {desktopSections.map((section) => ( + + ))} + + + + ) : null; + + const overviewContent = ( + + {isCompactLayout && visibleGroups.length > 1 ? ( + + + + + + {"AG ausw\u00e4hlen"} + + + {"Mobil zeigen wir jeweils eine AG auf einmal, damit die Budgetkarten sauber lesbar bleiben."} + + + setSelectedMobileGroupId(event.target.value)} + fullWidth + > + {visibleGroups.map((group) => ( + + {group.name} + + ))} + + + + + ) : null} + + + {(isCompactLayout ? (mobileSelectedGroup ? [mobileSelectedGroup] : []) : visibleGroups).map((group) => ( + + ))} + + + ); + + const desktopSectionContent = + desktopSection === "overview" ? ( + + {actionCards} + {overviewContent} + + ) : desktopSection === "periods" ? ( + + {canManagePeriods ? ( + + {periodManagementPanel} + + ) : null} + + ) : ( + {actionCards} + ); + + return ( + + + + + + + + + + Rave for Peace + + + {"RFP Finanz\u00fcbersicht"} + + + {`Aktuelle \u00dcbersicht: ${currentPeriod?.name ?? "Zeitraum fehlt"}. Alle AGs sind sichtbar, Mitglieder bearbeiten nur ihre eigene AG.`} + + + + + + + + + + } + label={`Freigegeben: ${currencyFormatter.format(totals.approved)}`} + sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} + /> + } + label={`Geplant: ${currencyFormatter.format(totals.pending)}`} + sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} + /> + } + label={`Budgets sichtbar: ${currencyFormatter.format(totals.budget)}`} + sx={{ bgcolor: alpha("#FFFFFF", 0.12), color: "white" }} + /> + + {isCompactLayout && currentPeriod ? ( + + ) : null} + {isCompactLayout && currentPeriod ? ( + + ) : null} + + + + + + + + + + {message ? {message.text} : null} + {periodOverviewCard} + + {isCompactLayout ? ( + + + setMobileSection(nextValue)} + variant="fullWidth" + > + + + + + {mobileSection === "overview" ? overviewContent : actionCards} + + ) : ( + {desktopSectionContent} + )} + + + + ); +} + + diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..baab7dd --- /dev/null +++ b/src/components/login-form.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Alert, Box, Button, Card, CardContent, Chip, Stack, TextField, Typography } from "@mui/material"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import type { FormEvent } from "react"; +import { useState } from "react"; + +const demoAccounts = [ + { label: "Vorstand A", username: "vorstand-a" }, + { label: "Vorstand B", username: "vorstand-b" }, + { label: "Finanz-AG", username: "finanzen" }, + { label: "Deko Mitglied", username: "deko" } +]; + +export function LoginForm() { + const router = useRouter(); + const [identifier, setIdentifier] = useState("vorstand-a"); + const [password, setPassword] = useState("demo123!"); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setIsLoading(true); + + const result = await signIn("credentials", { + identifier, + password, + redirect: false + }); + + setIsLoading(false); + + if (result?.error) { + setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pr\u00fcfen."); + return; + } + + router.push("/"); + router.refresh(); + } + + return ( + + + + + + Anmeldung + + + Melde dich mit deinem Login-Namen und Passwort an. + + + + + {demoAccounts.map((account) => ( + { + setIdentifier(account.username); + setPassword("demo123!"); + }} + variant="outlined" + /> + ))} + + + {"Demo-Passwort f\u00fcr alle Konten: demo123!"} + + {error ? {error} : null} + + + + setIdentifier(event.target.value)} + fullWidth + required + /> + setPassword(event.target.value)} + fullWidth + required + /> + + + + + + + ); +} diff --git a/src/components/providers/app-providers.tsx b/src/components/providers/app-providers.tsx new file mode 100644 index 0000000..72d1f2d --- /dev/null +++ b/src/components/providers/app-providers.tsx @@ -0,0 +1,46 @@ +"use client"; + +import type { PaletteMode } from "@mui/material"; +import { Box, CssBaseline, ThemeProvider } from "@mui/material"; +import { SessionProvider } from "next-auth/react"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { getAppTheme } from "@/theme"; + +type AppProvidersProps = { + children: ReactNode; +}; + +export function AppProviders({ children }: AppProvidersProps) { + const [mode, setMode] = useState(null); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + const applyMode = () => { + setMode(mediaQuery.matches ? "dark" : "light"); + }; + + applyMode(); + + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", applyMode); + return () => mediaQuery.removeEventListener("change", applyMode); + } + + mediaQuery.addListener(applyMode); + return () => mediaQuery.removeListener(applyMode); + }, []); + + const theme = useMemo(() => getAppTheme(mode ?? "dark"), [mode]); + + return ( + + + + {mode ? children : } + + + ); +} diff --git a/src/components/service-worker-registration.tsx b/src/components/service-worker-registration.tsx new file mode 100644 index 0000000..8f77ab5 --- /dev/null +++ b/src/components/service-worker-registration.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; + +export function ServiceWorkerRegistration() { + useEffect(() => { + if (!("serviceWorker" in navigator)) { + return; + } + + navigator.serviceWorker.register("/sw.js").catch(() => { + // Registrierung darf die App nicht blockieren. + }); + }, []); + + return null; +} + diff --git a/src/lib/accounting-periods.ts b/src/lib/accounting-periods.ts new file mode 100644 index 0000000..eb06daf --- /dev/null +++ b/src/lib/accounting-periods.ts @@ -0,0 +1,22 @@ +import prisma from "@/lib/prisma"; + +export async function getCurrentAccountingPeriod() { + const current = await prisma.accountingPeriod.findFirst({ + where: { + isCurrent: true + }, + orderBy: { + startsAt: "desc" + } + }); + + if (current) { + return current; + } + + return prisma.accountingPeriod.findFirst({ + orderBy: { + startsAt: "desc" + } + }); +} diff --git a/src/lib/audit-log.ts b/src/lib/audit-log.ts new file mode 100644 index 0000000..c79c6d4 --- /dev/null +++ b/src/lib/audit-log.ts @@ -0,0 +1,62 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +type AuditClient = PrismaClient | Prisma.TransactionClient; + +export type AuditRollbackPayload = { + kind: string; + [key: string]: Prisma.JsonValue | null | undefined; +}; + +type CreateAuditLogInput = { + actorId?: string | null; + action: string; + entityType: string; + entityId?: string | null; + entityLabel?: string | null; + summary: string; + metadata?: Prisma.InputJsonValue; +}; + +export function withRollbackMetadata( + details: Record = {}, + rollback?: AuditRollbackPayload | null +) { + return { + ...details, + rollback: rollback ?? null + } satisfies Prisma.InputJsonObject; +} + +export function getRollbackMetadata(metadata: Prisma.JsonValue | null | undefined) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return null; + } + + const rollback = (metadata as Record).rollback; + + if (!rollback || typeof rollback !== "object" || Array.isArray(rollback)) { + return null; + } + + const kind = (rollback as Record).kind; + + if (typeof kind !== "string" || kind.length === 0) { + return null; + } + + return rollback as Record & { kind: string }; +} + +export async function createAuditLog(client: AuditClient, input: CreateAuditLogInput) { + await client.auditLog.create({ + data: { + actorId: input.actorId ?? null, + action: input.action, + entityType: input.entityType, + entityId: input.entityId ?? null, + entityLabel: input.entityLabel ?? null, + summary: input.summary, + metadata: input.metadata + } + }); +} diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts new file mode 100644 index 0000000..e2c84f6 --- /dev/null +++ b/src/lib/audit-snapshots.ts @@ -0,0 +1,98 @@ +import type { Approval, AccountingPeriod, Budget, Expense, User, WorkingGroup } from "@prisma/client"; + +export function snapshotWorkingGroup(workingGroup: Pick) { + return { + id: workingGroup.id, + name: workingGroup.name, + createdAt: workingGroup.createdAt.toISOString() + }; +} + +export function snapshotPeriod(period: Pick) { + return { + id: period.id, + name: period.name, + startsAt: period.startsAt.toISOString(), + endsAt: period.endsAt.toISOString(), + isCurrent: period.isCurrent, + createdAt: period.createdAt.toISOString() + }; +} + +export function snapshotBudget(budget: Pick) { + return { + id: budget.id, + name: budget.name, + totalBudget: Number(budget.totalBudget), + colorCode: budget.colorCode, + workingGroupId: budget.workingGroupId, + periodId: budget.periodId, + createdAt: budget.createdAt.toISOString() + }; +} + +export function snapshotExpense( + expense: Pick< + Expense, + | "id" + | "title" + | "description" + | "amount" + | "creatorId" + | "agId" + | "budgetId" + | "periodId" + | "approvalStatus" + | "recurrence" + | "proofUrl" + | "createdAt" + | "paidAt" + | "documentedAt" + > +) { + return { + id: expense.id, + title: expense.title, + description: expense.description, + amount: Number(expense.amount), + creatorId: expense.creatorId, + agId: expense.agId, + budgetId: expense.budgetId, + periodId: expense.periodId, + approvalStatus: expense.approvalStatus, + recurrence: expense.recurrence, + proofUrl: expense.proofUrl, + createdAt: expense.createdAt.toISOString(), + paidAt: expense.paidAt?.toISOString() ?? null, + documentedAt: expense.documentedAt?.toISOString() ?? null + }; +} + +export function snapshotApproval(approval: Pick) { + return { + id: approval.id, + expenseId: approval.expenseId, + userId: approval.userId, + approvalType: approval.approvalType, + timestamp: approval.timestamp.toISOString() + }; +} + +export function snapshotUser( + user: Pick< + User, + "id" | "name" | "username" | "email" | "passwordHash" | "role" | "approvalPreference" | "workingGroupId" | "createdAt" + > +) { + return { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + passwordHash: user.passwordHash, + role: user.role, + approvalPreference: user.approvalPreference, + workingGroupId: user.workingGroupId, + createdAt: user.createdAt.toISOString() + }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..574c578 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,87 @@ +import type { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; + +import prisma from "@/lib/prisma"; + +const credentialsSchema = z.object({ + identifier: z.string().trim().min(2), + password: z.string().min(6) +}); + +export const authOptions: NextAuthOptions = { + session: { + strategy: "jwt" + }, + pages: { + signIn: "/login" + }, + providers: [ + CredentialsProvider({ + name: "Login und Passwort", + credentials: { + identifier: { label: "Login-Name", type: "text" }, + password: { label: "Passwort", type: "password" } + }, + async authorize(rawCredentials) { + const parsedCredentials = credentialsSchema.safeParse(rawCredentials); + + if (!parsedCredentials.success) { + return null; + } + + const normalizedIdentifier = parsedCredentials.data.identifier.toLowerCase(); + const matchedUser = await prisma.user.findUnique({ + where: { + username: normalizedIdentifier + } + }); + + if (!matchedUser) { + return null; + } + + const isPasswordValid = await bcrypt.compare(parsedCredentials.data.password, matchedUser.passwordHash); + + if (!isPasswordValid) { + return null; + } + + return { + id: matchedUser.id, + name: matchedUser.username, + username: matchedUser.username, + email: matchedUser.email, + role: matchedUser.role, + workingGroupId: matchedUser.workingGroupId, + approvalPreference: matchedUser.approvalPreference + }; + } + }) + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.username = user.username; + token.role = user.role; + token.workingGroupId = user.workingGroupId; + token.approvalPreference = user.approvalPreference; + } + + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id ?? ""; + session.user.username = token.username ?? ""; + session.user.role = token.role ?? "MEMBER"; + session.user.workingGroupId = token.workingGroupId ?? null; + session.user.approvalPreference = token.approvalPreference ?? null; + } + + return session; + } + } +}; diff --git a/src/lib/backup-csv.ts b/src/lib/backup-csv.ts new file mode 100644 index 0000000..e894c5a --- /dev/null +++ b/src/lib/backup-csv.ts @@ -0,0 +1,61 @@ +export function toCsvCell(value: string | number | null | undefined) { + if (value === null || value === undefined) { + return "\"\""; + } + + const normalized = String(value).replace(/"/g, "\"\""); + return `"${normalized}"`; +} + +export function parseCsv(content: string) { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentCell = ""; + let inQuotes = false; + + for (let index = 0; index < content.length; index += 1) { + const char = content[index]; + const nextChar = content[index + 1]; + + if (char === "\"") { + if (inQuotes && nextChar === "\"") { + currentCell += "\""; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === "," && !inQuotes) { + currentRow.push(currentCell); + currentCell = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && nextChar === "\n") { + index += 1; + } + + currentRow.push(currentCell); + rows.push(currentRow); + currentRow = []; + currentCell = ""; + continue; + } + + currentCell += char; + } + + if (currentCell.length > 0 || currentRow.length > 0) { + currentRow.push(currentCell); + rows.push(currentRow); + } + + if (rows[0]?.[0]?.charCodeAt(0) === 0xfeff) { + rows[0][0] = rows[0][0].slice(1); + } + + return rows; +} diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts new file mode 100644 index 0000000..e0d3a46 --- /dev/null +++ b/src/lib/dashboard-types.ts @@ -0,0 +1,99 @@ +import type { AppRole, ApprovalStatusValue, ApprovalTypeValue, ExpenseRecurrenceValue } from "@/lib/domain"; + +export type DashboardAccountingPeriod = { + id: string; + name: string; + startsAt: string; + endsAt: string; + isCurrent: boolean; +}; + +export type DashboardViewer = { + id: string; + name: string; + username: string; + role: AppRole; + workingGroupId: string | null; + approvalPreference: ApprovalTypeValue | null; +}; + +export type DashboardApproval = { + id: string; + approvalType: ApprovalTypeValue; + timestamp: string; + user: { + id: string; + name: string; + }; +}; + +export type DashboardExpense = { + id: string; + title: string; + description: string | null; + amount: number; + budgetId: string; + periodId: string; + approvalStatus: ApprovalStatusValue; + recurrence: ExpenseRecurrenceValue; + paidAt: string | null; + documentedAt: string | null; + proofUrl: string | null; + createdAt: string; + creator: { + id: string; + name: string; + }; + approvals: DashboardApproval[]; +}; + +export type DashboardBudget = { + id: string; + name: string; + totalBudget: number; + colorCode: string; + periodId: string; + expenses: DashboardExpense[]; +}; + +export type DashboardWorkingGroup = { + id: string; + name: string; + totalBudget: number; + members: { + id: string; + name: string; + username: string; + role: AppRole; + }[]; + budgets: DashboardBudget[]; +}; + +export type DashboardManagedUser = { + id: string; + name: string; + username: string; + role: AppRole; + workingGroupId: string | null; + workingGroupName: string | null; + approvalPreference: ApprovalTypeValue | null; + createdExpensesCount: number; + approvalsCount: number; +}; + +export type DashboardAuditLog = { + id: string; + action: string; + entityType: string; + entityId: string | null; + entityLabel: string | null; + summary: string; + canRestore: boolean; + createdAt: string; + actor: { + id: string; + name: string; + username: string; + role: AppRole; + } | null; +}; diff --git a/src/lib/domain.ts b/src/lib/domain.ts new file mode 100644 index 0000000..9679a11 --- /dev/null +++ b/src/lib/domain.ts @@ -0,0 +1,122 @@ +export const AUTO_APPROVAL_THRESHOLD = 50; + +export const APPROVAL_FLOW = ["CHAIR_A", "CHAIR_B", "FINANCE"] as const; +export const COLOR_PRESETS = [ + "#FFB94A", + "#68A35D", + "#5677F6", + "#FF8C69", + "#D567C8", + "#17A2B8", + "#A47A5B", + "#8E6CE7", + "#E05A73", + "#3FAF88" +] as const; + +export type AppRole = "ADMIN" | "FINANCE" | "MEMBER"; +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 roleLabel(role: AppRole) { + switch (role) { + case "ADMIN": + return "Vorstand"; + case "FINANCE": + return "Finanz-AG"; + case "MEMBER": + return "AG-Mitglied"; + } +} + +export function approvalLabel(approvalType: ApprovalTypeValue) { + switch (approvalType) { + case "CHAIR_A": + return "Vorstand A"; + case "CHAIR_B": + return "Vorstand B"; + case "FINANCE": + return "Finanz-AG"; + } +} + +export function recurrenceLabel(recurrence: ExpenseRecurrenceValue) { + switch (recurrence) { + case "MONTHLY": + return "Monatliches Abo"; + case "NONE": + return "Einmalig"; + } +} + +export function hasAdministrativeAccess(role: AppRole) { + return role === "ADMIN" || role === "FINANCE"; +} + +export function canManageBudgets(role: AppRole) { + return hasAdministrativeAccess(role); +} + +export function canManageUsers(role: AppRole) { + return hasAdministrativeAccess(role); +} + +export function canMarkPaid(role: AppRole) { + return hasAdministrativeAccess(role); +} + +export function canDocumentExpense(role: AppRole) { + return hasAdministrativeAccess(role); +} + +export function canCreateExpenseForGroup(role: AppRole, viewerGroupId: string | null, targetGroupId: string) { + if (hasAdministrativeAccess(role)) { + return true; + } + + return viewerGroupId === targetGroupId; +} + +export function canDeleteExpense( + role: AppRole, + viewerId: string, + creatorId: string, + approvalStatus: ApprovalStatusValue, + paidAt: string | null, + documentedAt: string | null +) { + if (role === "ADMIN" || role === "FINANCE") { + return true; + } + + return viewerId === creatorId && approvalStatus === "PENDING" && !paidAt && !documentedAt; +} + +export function getAvailableApprovalTypes( + role: AppRole, + approvalPreference: ApprovalTypeValue | null | undefined, + existingApprovals: ApprovalTypeValue[] +): ApprovalTypeValue[] { + const missingApprovals = APPROVAL_FLOW.filter( + (approvalType) => !existingApprovals.includes(approvalType) + ) as ApprovalTypeValue[]; + + if (role === "ADMIN") { + if (approvalPreference && missingApprovals.includes(approvalPreference)) { + return [approvalPreference, ...missingApprovals.filter((approvalType) => approvalType !== approvalPreference)]; + } + + return missingApprovals; + } + + if (role === "FINANCE") { + return missingApprovals.includes("FINANCE") ? ["FINANCE"] : []; + } + + return []; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..c9dcacc --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as unknown as { + prisma?: PrismaClient; +}; + +const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"] + }); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} + +export default prisma; diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..9614536 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,19 @@ +import { getServerSession } from "next-auth"; + +import { authOptions } from "@/lib/auth"; +import prisma from "@/lib/prisma"; + +export async function getCurrentViewer() { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return null; + } + + return prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + workingGroup: true + } + }); +} diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..8058432 --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,136 @@ +import type { PaletteMode } from "@mui/material"; +import { alpha, createTheme } from "@mui/material/styles"; + +const primaryLight = "#3B5AE0"; +const primaryDark = "#8CA4FF"; +const secondaryLight = "#F28B4B"; +const secondaryDark = "#FFB36B"; +const successLight = "#2F8F64"; +const successDark = "#57C089"; +const deepLight = "#17203A"; +const deepDark = "#E9EEF9"; +const surfaceLight = "#F5F1E8"; +const paperLight = "#FFFDF9"; +const surfaceDark = "#0B1020"; +const paperDark = "#121A2B"; + +export function getAppTheme(mode: PaletteMode) { + const isDark = mode === "dark"; + const primary = isDark ? primaryDark : primaryLight; + const secondary = isDark ? secondaryDark : secondaryLight; + const success = isDark ? successDark : successLight; + const textPrimary = isDark ? deepDark : deepLight; + const textSecondary = alpha(textPrimary, isDark ? 0.76 : 0.7); + const backgroundDefault = isDark ? surfaceDark : surfaceLight; + const backgroundPaper = isDark ? paperDark : paperLight; + + return createTheme({ + palette: { + mode, + primary: { + main: primary + }, + secondary: { + main: secondary + }, + background: { + default: backgroundDefault, + paper: backgroundPaper + }, + text: { + primary: textPrimary, + secondary: textSecondary + }, + success: { + main: success + }, + divider: alpha(textPrimary, isDark ? 0.12 : 0.08) + }, + shape: { + borderRadius: 24 + }, + typography: { + fontFamily: "var(--font-body)", + h1: { + fontFamily: "var(--font-display)", + fontSize: "clamp(2.4rem, 5vw, 4.5rem)", + fontWeight: 700, + letterSpacing: "-0.04em" + }, + h2: { + fontFamily: "var(--font-display)", + fontWeight: 700, + letterSpacing: "-0.03em" + }, + h3: { + fontFamily: "var(--font-display)", + fontWeight: 700 + }, + button: { + fontWeight: 700, + textTransform: "none" + } + }, + components: { + MuiCssBaseline: { + styleOverrides: { + ":root": { + colorScheme: mode + }, + body: { + background: isDark + ? [ + `radial-gradient(circle at top left, ${alpha(secondary, 0.16)}, transparent 30%)`, + `radial-gradient(circle at right 18%, ${alpha(primary, 0.2)}, transparent 26%)`, + "linear-gradient(180deg, #0b1020 0%, #111a2b 100%)" + ].join(", ") + : [ + `radial-gradient(circle at top left, ${alpha(secondary, 0.22)}, transparent 34%)`, + `radial-gradient(circle at right 20%, ${alpha(primary, 0.2)}, transparent 28%)`, + "linear-gradient(180deg, #faf6ef 0%, #f3eee4 100%)" + ].join(", "), + color: textPrimary + } + } + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 28, + border: `1px solid ${alpha(textPrimary, isDark ? 0.12 : 0.08)}`, + boxShadow: isDark + ? `0 24px 64px ${alpha("#000000", 0.34)}` + : `0 18px 48px ${alpha(primary, 0.12)}` + } + } + }, + MuiButton: { + defaultProps: { + disableElevation: true + }, + styleOverrides: { + root: { + borderRadius: 999, + paddingInline: 18 + } + } + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 999 + } + } + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: "none" + } + } + } + } + }); +} + +export const appTheme = getAppTheme("light"); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..908a6f6 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,32 @@ +import type { ApprovalType, Role } from "@prisma/client"; +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + username: string; + role: Role; + workingGroupId: string | null; + approvalPreference: ApprovalType | null; + } & DefaultSession["user"]; + } + + interface User { + id: string; + username: string; + role: Role; + workingGroupId: string | null; + approvalPreference: ApprovalType | null; + } +} + +declare module "next-auth/jwt" { + interface JWT { + id?: string; + username?: string; + role?: Role; + workingGroupId?: string | null; + approvalPreference?: ApprovalType | null; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ce8e47 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +