Initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -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"
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
uploads
|
||||||
|
public/uploads
|
||||||
|
prisma/dev.db
|
||||||
|
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -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"]
|
||||||
65
README.md
Normal file
65
README.md
Normal file
@@ -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.
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -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:
|
||||||
14
docker/entrypoint.sh
Normal file
14
docker/entrypoint.sh
Normal file
@@ -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
|
||||||
|
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// This file is auto-generated by Next.js.
|
||||||
|
|
||||||
4
next.config.mjs
Normal file
4
next.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
38
package.json
Normal file
38
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
prisma/migrations/202604070001_init/migration.sql
Normal file
81
prisma/migrations/202604070001_init/migration.sql
Normal file
@@ -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;
|
||||||
|
|
||||||
84
prisma/migrations/202604071700_budget_accounts/migration.sql
Normal file
84
prisma/migrations/202604071700_budget_accounts/migration.sql
Normal file
@@ -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";
|
||||||
@@ -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;
|
||||||
20
prisma/migrations/202604081930_audit_logs/migration.sql
Normal file
20
prisma/migrations/202604081930_audit_logs/migration.sql
Normal file
@@ -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;
|
||||||
146
prisma/schema.prisma
Normal file
146
prisma/schema.prisma
Normal file
@@ -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")
|
||||||
|
}
|
||||||
250
prisma/seed.ts
Normal file
250
prisma/seed.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 651 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
9
public/icon.svg
Normal file
9
public/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#17203A"/>
|
||||||
|
<rect x="44" y="48" width="40" height="150" rx="18" fill="#F28B4B" opacity="0.4"/>
|
||||||
|
<rect x="108" y="36" width="40" height="162" rx="18" fill="#3B5AE0"/>
|
||||||
|
<rect x="172" y="76" width="40" height="122" rx="18" fill="#68A35D"/>
|
||||||
|
<path d="M70 166l14 14 26-32" fill="none" stroke="#F7F4EE" stroke-linecap="round" stroke-linejoin="round" stroke-width="12"/>
|
||||||
|
<path d="M182 154l12 12 22-28" fill="none" stroke="#F7F4EE" stroke-linecap="round" stroke-linejoin="round" stroke-width="12"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 615 B |
45
public/sw.js
Normal file
45
public/sw.js
Normal file
@@ -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("/");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
516
src/app/api/audit-logs/[id]/restore/route.ts
Normal file
516
src/app/api/audit-logs/[id]/restore/route.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/api/auth/[...nextauth]/route.ts
Normal file
8
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -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 };
|
||||||
|
|
||||||
147
src/app/api/budgets/[id]/route.ts
Normal file
147
src/app/api/budgets/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
115
src/app/api/budgets/route.ts
Normal file
115
src/app/api/budgets/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
124
src/app/api/expenses/[id]/approve/route.ts
Normal file
124
src/app/api/expenses/[id]/approve/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
84
src/app/api/expenses/[id]/documented/route.ts
Normal file
84
src/app/api/expenses/[id]/documented/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
62
src/app/api/expenses/[id]/paid/route.ts
Normal file
62
src/app/api/expenses/[id]/paid/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
63
src/app/api/expenses/[id]/route.ts
Normal file
63
src/app/api/expenses/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
86
src/app/api/expenses/route.ts
Normal file
86
src/app/api/expenses/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
518
src/app/api/export/csv/route.ts
Normal file
518
src/app/api/export/csv/route.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
239
src/app/api/import/csv/route.ts
Normal file
239
src/app/api/import/csv/route.ts
Normal file
@@ -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<string, string>;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/periods/[id]/route.ts
Normal file
73
src/app/api/periods/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
80
src/app/api/periods/current/route.ts
Normal file
80
src/app/api/periods/current/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
96
src/app/api/periods/route.ts
Normal file
96
src/app/api/periods/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
74
src/app/api/users/[id]/password/route.ts
Normal file
74
src/app/api/users/[id]/password/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
83
src/app/api/users/[id]/route.ts
Normal file
83
src/app/api/users/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
118
src/app/api/users/route.ts
Normal file
118
src/app/api/users/route.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
148
src/app/api/working-groups/[id]/route.ts
Normal file
148
src/app/api/working-groups/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
67
src/app/api/working-groups/route.ts
Normal file
67
src/app/api/working-groups/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
44
src/app/globals.css
Normal file
44
src/app/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
50
src/app/layout.tsx
Normal file
50
src/app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="de">
|
||||||
|
<body className={`${displayFont.variable} ${bodyFont.variable}`}>
|
||||||
|
<AppProviders>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
|
{children}
|
||||||
|
</AppProviders>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/login/page.tsx
Normal file
34
src/app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", px: 2 }}>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box sx={{ maxWidth: 720 }}>
|
||||||
|
<Typography variant="h1" gutterBottom>
|
||||||
|
{"RFP Finanz\u00fcbersicht"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
{"Material-3-orientierter MVP f\u00fcr Budget\u00fcbersicht, Freigaben und Dokumentation im Verein."}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LoginForm />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/manifest.ts
Normal file
32
src/app/manifest.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
239
src/app/page.tsx
Normal file
239
src/app/page.tsx
Normal file
@@ -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 (
|
||||||
|
<DashboardShell
|
||||||
|
viewer={serializedViewer}
|
||||||
|
workingGroups={serializedGroups}
|
||||||
|
managedUsers={serializedUsers}
|
||||||
|
auditLogs={serializedAuditLogs}
|
||||||
|
accountingPeriods={serializedPeriods}
|
||||||
|
currentPeriodId={currentPeriod.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
743
src/components/dashboard/budget-column.tsx
Normal file
743
src/components/dashboard/budget-column.tsx
Normal file
@@ -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<void>;
|
||||||
|
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||||
|
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||||
|
onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>;
|
||||||
|
onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>;
|
||||||
|
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
||||||
|
onDeleteBudget: (budgetId: string) => Promise<void>;
|
||||||
|
onDeleteExpense: (expenseId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Chip
|
||||||
|
label={expense.approvalStatus === "APPROVED" ? "Freigegeben" : "Geplant"}
|
||||||
|
color={expense.approvalStatus === "APPROVED" ? "primary" : "default"}
|
||||||
|
variant={expense.approvalStatus === "APPROVED" ? "filled" : "outlined"}
|
||||||
|
size="small"
|
||||||
|
sx={wrappingChipSx}
|
||||||
|
/>
|
||||||
|
{expense.paidAt ? (
|
||||||
|
<Chip label="Bezahlt" color="info" size="small" icon={<CheckCircleRoundedIcon />} sx={wrappingChipSx} />
|
||||||
|
) : null}
|
||||||
|
{expense.documentedAt ? (
|
||||||
|
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
||||||
|
) : null}
|
||||||
|
{expense.recurrence === "MONTHLY" ? (
|
||||||
|
<Chip
|
||||||
|
label={recurrenceLabel(expense.recurrence)}
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={wrappingChipSx}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<string, BudgetDraft>>({});
|
||||||
|
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
|
||||||
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
|
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
||||||
|
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
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<BudgetDraft>) {
|
||||||
|
setBudgetDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[budget.id]: {
|
||||||
|
...getDraft(budget),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDraft(budget: DashboardBudget) {
|
||||||
|
setBudgetDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[budget.id]: createDraft(budget)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
minWidth: { xs: 0, md: groupCardWidth },
|
||||||
|
width: { xs: "100%", md: groupCardWidth },
|
||||||
|
maxWidth: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: alpha(theme.palette.background.paper, isDark ? 0.84 : 0.84),
|
||||||
|
backdropFilter: "blur(8px)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: "flex-start", sm: "flex-start" }}
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="h3" sx={{ fontSize: "1.45rem", overflowWrap: "break-word" }}>
|
||||||
|
{group.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Gesamtbudgets: {formatCurrency(group.totalBudget)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
{totalCommitted > group.totalBudget ? <Chip label={"\u00dcber Budget"} color="error" size="small" /> : null}
|
||||||
|
{canEditBudgets ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
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 ? <CloseRoundedIcon fontSize="small" /> : <EditRoundedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isEditingGroup ? (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
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%)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<TextField
|
||||||
|
label="AG-Name"
|
||||||
|
size="small"
|
||||||
|
value={groupDraftName}
|
||||||
|
onChange={(event) => setGroupDraftName(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
AGs lassen sich nur loeschen, wenn keine Mitglieder, Budgets oder Ausgaben mehr daran haengen.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button type="submit" variant="contained" disabled={busy}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setGroupDraftName(group.name);
|
||||||
|
setIsEditingGroup(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DeleteOutlineRoundedIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`AG "${group.name}" wirklich loeschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDeleteWorkingGroup(group.id, group.name);
|
||||||
|
setIsEditingGroup(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AG loeschen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Chip
|
||||||
|
icon={<DoneAllRoundedIcon />}
|
||||||
|
label={`Freigegeben: ${formatCurrency(approvedSpend)}`}
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content" }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<ReceiptLongRoundedIcon />}
|
||||||
|
label={`Geplant: ${formatCurrency(pendingSpend)}`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content" }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<EuroRoundedIcon />}
|
||||||
|
label={`Rest: ${formatCurrency(remainingBudget)}`}
|
||||||
|
color={remainingBudget < 0 ? "error" : "default"}
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{group.budgets.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: "18px",
|
||||||
|
backgroundColor: alpha(theme.palette.text.primary, isDark ? 0.08 : 0.04)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">In dieser AG gibt es noch keine Budgets.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={2}
|
||||||
|
sx={{ overflowX: "auto", pb: 1.5, alignItems: "stretch", scrollSnapType: "x proximity" }}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<Box
|
||||||
|
key={budget.id}
|
||||||
|
sx={{
|
||||||
|
minWidth: { xs: 280, sm: 312, md: budgetCardWidth },
|
||||||
|
width: { xs: "84vw", sm: 312, md: budgetCardWidth },
|
||||||
|
maxWidth: { xs: 360, md: budgetCardWidth },
|
||||||
|
flex: "0 0 auto",
|
||||||
|
scrollSnapAlign: "start"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.25,
|
||||||
|
borderRadius: "18px",
|
||||||
|
border: `1px solid ${alpha(budget.colorCode, 0.22)}`,
|
||||||
|
background: `linear-gradient(180deg, ${alpha(budget.colorCode, isDark ? 0.18 : 0.12)} 0%, ${alpha(theme.palette.background.paper, isDark ? 0.96 : 0.92)} 28%)`,
|
||||||
|
overflow: "hidden",
|
||||||
|
height: "100%"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1.5}>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" gap={1.2} alignItems="center">
|
||||||
|
<Box sx={{ width: 18, height: 18, borderRadius: "50%", bgcolor: budget.colorCode, flexShrink: 0 }} />
|
||||||
|
<Typography variant="h4" sx={{ fontSize: "1.15rem", overflowWrap: "break-word" }}>
|
||||||
|
{budget.name}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography color="text.secondary">Budget: {formatCurrency(budget.totalBudget)}</Typography>
|
||||||
|
</Box>
|
||||||
|
{canEditBudgets ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
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 ? <CloseRoundedIcon fontSize="small" /> : <EditRoundedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} gap={2} alignItems="stretch">
|
||||||
|
<Box sx={{ width: { xs: "100%", sm: 110 }, display: "grid", placeItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: 96,
|
||||||
|
height: 232,
|
||||||
|
borderRadius: "30px",
|
||||||
|
border: `3px solid ${alpha(budget.colorCode, 0.82)}`,
|
||||||
|
backgroundColor: alpha(budget.colorCode, isDark ? 0.12 : 0.06),
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
insetInline: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: `${cumulativePercent}%`,
|
||||||
|
backgroundColor: alpha(budget.colorCode, 0.3),
|
||||||
|
transition: "height 220ms ease"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
insetInline: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: `${approvedPercent}%`,
|
||||||
|
backgroundColor: budget.colorCode,
|
||||||
|
transition: "height 220ms ease"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={1.2} sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Chip
|
||||||
|
icon={<DoneAllRoundedIcon />}
|
||||||
|
label={`Freigegeben: ${formatCurrency(budgetApproved)}`}
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content", bgcolor: alpha(budget.colorCode, 0.14) }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<ReceiptLongRoundedIcon />}
|
||||||
|
label={`Geplant: ${formatCurrency(budgetPending)}`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content" }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<EuroRoundedIcon />}
|
||||||
|
label={`Rest: ${formatCurrency(budgetRemaining)}`}
|
||||||
|
color={budgetRemaining < 0 ? "error" : "default"}
|
||||||
|
sx={{ ...wrappingChipSx, width: "fit-content" }}
|
||||||
|
/>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
{"Unter 50 EUR werden sofort freigegeben. Gr\u00f6\u00dfere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen."}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await onSaveBudget(budget.id, draft.name, draft.totalBudget, draft.colorCode);
|
||||||
|
setEditingBudgetId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<TextField
|
||||||
|
label="Budget-Name"
|
||||||
|
value={draft.name}
|
||||||
|
size="small"
|
||||||
|
onChange={(event) => updateDraft(budget, { name: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Budgetbetrag"
|
||||||
|
value={draft.totalBudget}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 0, step: 0.01 }}
|
||||||
|
onChange={(event) => updateDraft(budget, { totalBudget: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<ColorPickerField
|
||||||
|
label="Budgetfarbe"
|
||||||
|
value={draft.colorCode}
|
||||||
|
onChange={(value) => updateDraft(budget, { colorCode: value })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button type="submit" variant="contained" disabled={busy}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
resetDraft(budget);
|
||||||
|
setEditingBudgetId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DeleteOutlineRoundedIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Budget "${budget.name}" wirklich l\u00f6schen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDeleteBudget(budget.id);
|
||||||
|
setEditingBudgetId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"Budget l\u00f6schen"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{budget.expenses.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: "16px",
|
||||||
|
backgroundColor: alpha(budget.colorCode, isDark ? 0.14 : 0.08)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Noch keine Ausgaben in diesem Budget.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : 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 (
|
||||||
|
<Box
|
||||||
|
key={expense.id}
|
||||||
|
sx={{
|
||||||
|
p: 2.25,
|
||||||
|
borderRadius: "18px",
|
||||||
|
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
||||||
|
backgroundColor:
|
||||||
|
expense.approvalStatus === "APPROVED"
|
||||||
|
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
|
||||||
|
: alpha(budget.colorCode, isDark ? 0.1 : 0.04)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ fontWeight: 700, overflowWrap: "break-word" }}
|
||||||
|
>
|
||||||
|
{expense.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
|
{formatCurrency(expense.amount)} von {expense.creator.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<StatusChips expense={expense} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{expense.description ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
|
{expense.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{requiresManualApproval(expense.amount) ? (
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
{APPROVAL_FLOW.map((approvalType) => {
|
||||||
|
const matchingApproval = expense.approvals.find(
|
||||||
|
(approval) => approval.approvalType === approvalType
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={approvalType}
|
||||||
|
label={
|
||||||
|
matchingApproval
|
||||||
|
? `${approvalLabel(approvalType)}: ${matchingApproval.user.name}`
|
||||||
|
: `${approvalLabel(approvalType)}: offen`
|
||||||
|
}
|
||||||
|
color={matchingApproval ? "primary" : "default"}
|
||||||
|
variant={matchingApproval ? "filled" : "outlined"}
|
||||||
|
size="small"
|
||||||
|
sx={wrappingChipSx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{expense.proofUrl ? (
|
||||||
|
<Link
|
||||||
|
href={expense.proofUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
underline="hover"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{"Beleg \u00f6ffnen"}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
{availableApprovals.map((approvalType) => (
|
||||||
|
<Button
|
||||||
|
key={approvalType}
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onApprove(expense.id, approvalType)}
|
||||||
|
>
|
||||||
|
Freigeben als {approvalLabel(approvalType)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!expense.paidAt && expense.approvalStatus === "APPROVED" && canMarkPaid(viewer.role) ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onMarkPaid(expense.id)}
|
||||||
|
>
|
||||||
|
Bezahlt setzen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canDeleteExpense(
|
||||||
|
viewer.role,
|
||||||
|
viewer.id,
|
||||||
|
expense.creator.id,
|
||||||
|
expense.approvalStatus,
|
||||||
|
expense.paidAt,
|
||||||
|
expense.documentedAt
|
||||||
|
) ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DeleteOutlineRoundedIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Ausgabe "${expense.title}" wirklich l\u00f6schen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDeleteExpense(expense.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"L\u00f6schen"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? (
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||||
|
<TextField
|
||||||
|
label="Beleg-URL"
|
||||||
|
value={proofUrlDrafts[expense.id] ?? expense.proofUrl ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProofUrlDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
onDocument(expense.id, proofUrlDrafts[expense.id] ?? expense.proofUrl ?? undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Dokumentieren
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Angelegt am{" "}
|
||||||
|
{new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(
|
||||||
|
new Date(expense.createdAt)
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/dashboard/color-picker-field.tsx
Normal file
46
src/components/dashboard/color-picker-field.tsx
Normal file
@@ -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 (
|
||||||
|
<Stack spacing={1.2}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
{COLOR_PRESETS.map((preset) => (
|
||||||
|
<Box
|
||||||
|
key={preset}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Farbe ${preset}`}
|
||||||
|
onClick={() => 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"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
1835
src/components/dashboard/dashboard-shell.tsx
Normal file
1835
src/components/dashboard/dashboard-shell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
109
src/components/login-form.tsx
Normal file
109
src/components/login-form.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
maxWidth: 520,
|
||||||
|
width: "100%",
|
||||||
|
mx: "auto",
|
||||||
|
overflow: "visible"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h2" gutterBottom>
|
||||||
|
Anmeldung
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Melde dich mit deinem Login-Namen und Passwort an.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" useFlexGap flexWrap="wrap" gap={1}>
|
||||||
|
{demoAccounts.map((account) => (
|
||||||
|
<Chip
|
||||||
|
key={account.username}
|
||||||
|
label={account.label}
|
||||||
|
onClick={() => {
|
||||||
|
setIdentifier(account.username);
|
||||||
|
setPassword("demo123!");
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Alert severity="info">{"Demo-Passwort f\u00fcr alle Konten: demo123!"}</Alert>
|
||||||
|
|
||||||
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Login-Name"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="large" variant="contained" disabled={isLoading}>
|
||||||
|
{isLoading ? "Anmeldung l\u00e4uft..." : "Einloggen"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/providers/app-providers.tsx
Normal file
46
src/components/providers/app-providers.tsx
Normal file
@@ -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<PaletteMode | null>(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 (
|
||||||
|
<SessionProvider>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline enableColorScheme />
|
||||||
|
{mode ? children : <Box sx={{ minHeight: "100vh" }} />}
|
||||||
|
</ThemeProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/service-worker-registration.tsx
Normal file
18
src/components/service-worker-registration.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
22
src/lib/accounting-periods.ts
Normal file
22
src/lib/accounting-periods.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
62
src/lib/audit-log.ts
Normal file
62
src/lib/audit-log.ts
Normal file
@@ -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<string, Prisma.JsonValue | null | undefined> = {},
|
||||||
|
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<string, unknown>).rollback;
|
||||||
|
|
||||||
|
if (!rollback || typeof rollback !== "object" || Array.isArray(rollback)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = (rollback as Record<string, unknown>).kind;
|
||||||
|
|
||||||
|
if (typeof kind !== "string" || kind.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rollback as Record<string, Prisma.JsonValue | null | undefined> & { 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
98
src/lib/audit-snapshots.ts
Normal file
98
src/lib/audit-snapshots.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Approval, AccountingPeriod, Budget, Expense, User, WorkingGroup } from "@prisma/client";
|
||||||
|
|
||||||
|
export function snapshotWorkingGroup(workingGroup: Pick<WorkingGroup, "id" | "name" | "createdAt">) {
|
||||||
|
return {
|
||||||
|
id: workingGroup.id,
|
||||||
|
name: workingGroup.name,
|
||||||
|
createdAt: workingGroup.createdAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotPeriod(period: Pick<AccountingPeriod, "id" | "name" | "startsAt" | "endsAt" | "isCurrent" | "createdAt">) {
|
||||||
|
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<Budget, "id" | "name" | "totalBudget" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">) {
|
||||||
|
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<Approval, "id" | "expenseId" | "userId" | "approvalType" | "timestamp">) {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/lib/auth.ts
Normal file
87
src/lib/auth.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
61
src/lib/backup-csv.ts
Normal file
61
src/lib/backup-csv.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
99
src/lib/dashboard-types.ts
Normal file
99
src/lib/dashboard-types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
122
src/lib/domain.ts
Normal file
122
src/lib/domain.ts
Normal file
@@ -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 [];
|
||||||
|
}
|
||||||
17
src/lib/prisma.ts
Normal file
17
src/lib/prisma.ts
Normal file
@@ -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;
|
||||||
19
src/lib/session.ts
Normal file
19
src/lib/session.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
136
src/theme.ts
Normal file
136
src/theme.ts
Normal file
@@ -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");
|
||||||
32
src/types/next-auth.d.ts
vendored
Normal file
32
src/types/next-auth.d.ts
vendored
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user