commit cc72f5efd7422755867ad55b2357ae3b968a7512 Author: Florian Wilde Date: Sat Aug 22 01:24:30 2026 +0200 chore: Projektgeruest mit Next.js, TypeScript und Tailwind Grundgeruest der Anwendung: Next.js 15 (App Router), TypeScript im strict-Modus, Tailwind mit shadcn/ui-Farbtokens. Bewusste Festlegungen: - Nur Systemschriften, keine externen Fonts, kein CDN. - Grundschrift eine Stufe groesser, Fokusring immer sichtbar, Touchziele mindestens 44px. - Ampelfarben fuer Fristen als eigene Design-Tokens. - Betraege werden als Cent-Ganzzahl gefuehrt, damit die Rechnungslegung keine Rundungsfehler erbt. - .env.example dokumentiert alle Geheimnisse; npm run schluessel erzeugt sie. Etappe: 1 Phase: Fundament diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..869526e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.next +.git +.env +.env.local +backups +uploads +postgres-data +caddy-data +caddy-config +coverage +test-results +playwright-report +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..723870d --- /dev/null +++ b/.env.example @@ -0,0 +1,63 @@ +# ============================================================================= +# Betreuungs-Cockpit - Beispielkonfiguration +# Kopiere diese Datei nach .env und fuelle die Werte aus. +# Alle Geheimnisse erzeugst du mit: npm run schluessel +# ============================================================================= + +# --- Datenbank -------------------------------------------------------------- +# Lokal (Docker Compose): Host ist der Servicename "db". +POSTGRES_USER=cockpit +POSTGRES_PASSWORD=bitte-aendern +POSTGRES_DB=betreuungs_cockpit +DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockpit?schema=public" + +# --- Auth.js ---------------------------------------------------------------- +# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel +AUTH_SECRET= +# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). +AUTH_URL=http://localhost:3000 +# Hinter einem Reverse Proxy (Caddy) auf true setzen. +AUTH_TRUST_HOST=true + +# --- Anwendungsseitige Feldverschluesselung --------------------------------- +# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN +# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern! +# Schluesselrotation: neuen Schluessel als ENC_KEY_V2 ergaenzen und +# ENC_KEY_AKTIV=2 setzen. Alte Schluessel bleiben zum Entschluesseln stehen. +ENC_KEY_V1= +ENC_KEY_AKTIV=1 + +# Separater Schluessel fuer Blind-Indizes (HMAC-SHA256). +# Erlaubt die Suche nach z.B. einer IBAN, ohne sie im Klartext zu speichern. +INDEX_KEY= + +# --- Sicherheit ------------------------------------------------------------- +# Leerlauf bis zur automatischen Abmeldung (Sekunden). 30 Minuten. +SESSION_MAX_AGE=1800 +# Wie lange eine erneute Anmeldung fuer sensible Aktionen gilt (Sekunden). +REAUTH_MAX_AGE=300 +# Login-Sperre: max. Fehlversuche pro Zeitfenster (Sekunden). +LOGIN_MAX_VERSUCHE=5 +LOGIN_FENSTER_SEKUNDEN=900 +LOGIN_SPERRE_SEKUNDEN=900 + +# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------ +# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein. +# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen. +S3_ENDPOINT=https://fsn1.your-objectstorage.com +S3_REGION=eu-central +S3_BUCKET=betreuungs-cockpit +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ---------------------------- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM="Betreuungs-Cockpit " + +# --- Erstanmeldung ---------------------------------------------------------- +# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen. +SEED_ADMIN_EMAIL=fw@wildeflorian.de +SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f6ac50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Abhaengigkeiten +node_modules/ +.pnp +.pnp.js + +# Build +.next/ +out/ +build/ +dist/ +next-env.d.ts +*.tsbuildinfo + +# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo +.env +.env.local +.env.*.local +!.env.example + +# Daten und Backups +backups/ +uploads/ +postgres-data/ +caddy-data/ +caddy-config/ +*.dump +*.sql.gz + +# Test-Artefakte +coverage/ +playwright-report/ +test-results/ +.playwright/ + +# Werkzeuge +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.log +npm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..e32d9ad --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Betreuungs-Cockpit + +Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung +eines Angehörigen — Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe. + +> Die Anwendung erteilt **keine Rechtsberatung**. Alle Hinweise mit +> Paragraphen-Bezug ersetzen nicht die Rückfrage bei Betreuungsgericht, +> Sozialhilfeträger oder Anwalt. + +Die vollständige Installations-, Betriebs- und Backup-Anleitung folgt in +diesem Dokument, sobald Etappe 1 steht. diff --git a/components.json b/components.json new file mode 100644 index 0000000..21584ab --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..bf43217 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,36 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Standalone-Build fuer ein schlankes Docker-Image. + output: 'standalone', + reactStrictMode: true, + poweredByHeader: false, + // Argon2 ist eine native Bibliothek und darf nicht gebundelt werden. + serverExternalPackages: ['@node-rs/argon2'], + eslint: { + ignoreDuringBuilds: false, + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { key: 'X-DNS-Prefetch-Control', value: 'off' }, + { + key: 'Permissions-Policy', + // Kamera bleibt erlaubt: Belege werden am Handy fotografiert. + value: 'camera=(self), microphone=(self), geolocation=(), interest-cohort=()', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + ], + }, + ] + }, +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..c971e5e --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "betreuungs-cockpit", + "version": "0.1.0", + "private": true, + "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", + "engines": { + "node": ">=20.11" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", + "db:seed": "prisma db seed", + "db:studio": "prisma studio", + "setup": "prisma migrate deploy && npm run db:volltext && npm run db:seed", + "schluessel": "node scripts/schluessel-erzeugen.mjs", + "postinstall": "prisma generate" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@node-rs/argon2": "2.0.2", + "@prisma/client": "6.3.1", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-slot": "1.1.1", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "date-fns": "4.1.0", + "lucide-react": "0.474.0", + "next": "15.1.6", + "next-auth": "5.0.0-beta.25", + "otpauth": "9.3.6", + "qrcode": "1.5.4", + "react": "19.0.0", + "react-dom": "19.0.0", + "tailwind-merge": "2.6.0", + "tailwindcss-animate": "1.0.7", + "zod": "3.24.1" + }, + "devDependencies": { + "@types/node": "22.13.1", + "@types/qrcode": "1.5.5", + "@types/react": "19.0.8", + "@types/react-dom": "19.0.3", + "autoprefixer": "10.4.20", + "eslint": "9.19.0", + "eslint-config-next": "15.1.6", + "postcss": "8.5.1", + "prisma": "6.3.1", + "tailwindcss": "3.4.17", + "tsx": "4.19.2", + "typescript": "5.7.3", + "vitest": "3.0.5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/scripts/schluessel-erzeugen.mjs b/scripts/schluessel-erzeugen.mjs new file mode 100644 index 0000000..043168b --- /dev/null +++ b/scripts/schluessel-erzeugen.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Erzeugt die Geheimnisse fuer die .env-Datei. + * + * Aufruf: npm run schluessel + * + * Die Werte werden bewusst nur ausgegeben und nirgends gespeichert. + * Kopiere sie in die .env und sichere ENC_KEY_V1 zusaetzlich getrennt + * vom Datenbank-Backup (z.B. Passwortmanager, Papier im Ordner). + */ +import { randomBytes } from 'node:crypto' + +const b64 = (bytes) => randomBytes(bytes).toString('base64') + +console.log(` +# --- Erzeugt am ${new Date().toISOString()} --- +AUTH_SECRET=${b64(32)} +ENC_KEY_V1=${b64(32)} +ENC_KEY_AKTIV=1 +INDEX_KEY=${b64(32)} +POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')} + +# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen. +# Ohne ihn sind Diagnosen, IBANs, Versicherungsnummern und Notizen +# aus dem Backup nicht mehr lesbar. +`) diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..1321830 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,139 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Helles Thema - bewusst hoher Kontrast, kein Grau-auf-Grau. */ + --background: 0 0% 100%; + --foreground: 222 47% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + + --primary: 214 80% 32%; + --primary-foreground: 0 0% 100%; + + --secondary: 214 32% 94%; + --secondary-foreground: 222 47% 11%; + + --muted: 214 32% 95%; + --muted-foreground: 215 20% 35%; + + --accent: 214 32% 94%; + --accent-foreground: 222 47% 11%; + + --destructive: 0 72% 40%; + --destructive-foreground: 0 0% 100%; + + --border: 214 20% 84%; + --input: 214 20% 80%; + --ring: 214 80% 40%; + + --ampel-rot: 0 72% 42%; + --ampel-orange: 25 90% 38%; + --ampel-gelb: 43 90% 30%; + --ampel-gruen: 145 63% 27%; + + --radius: 0.5rem; + } + + .dark { + --background: 222 47% 9%; + --foreground: 210 40% 96%; + + --card: 222 45% 12%; + --card-foreground: 210 40% 96%; + + --popover: 222 45% 12%; + --popover-foreground: 210 40% 96%; + + --primary: 210 90% 66%; + --primary-foreground: 222 47% 11%; + + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + + --muted: 217 33% 17%; + --muted-foreground: 215 25% 72%; + + --accent: 217 33% 20%; + --accent-foreground: 210 40% 96%; + + --destructive: 0 72% 62%; + --destructive-foreground: 222 47% 11%; + + --border: 217 25% 26%; + --input: 217 25% 30%; + --ring: 210 90% 66%; + + --ampel-rot: 0 84% 68%; + --ampel-orange: 25 95% 62%; + --ampel-gelb: 45 95% 60%; + --ampel-gruen: 145 60% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + + html { + /* Verhindert das automatische Hochskalieren von Text in iOS-Safari. */ + -webkit-text-size-adjust: 100%; + } + + body { + @apply bg-background text-foreground antialiased; + } + + /* Tastaturbedienung muss sichtbar sein - niemals outline:none. */ + :focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; + } + + /* Auf Touch-Geraeten sollen Bedienelemente mindestens 44px hoch sein. */ + @media (pointer: coarse) { + button, + [role='button'], + a.btn, + input, + select, + textarea { + min-height: 2.75rem; + } + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } +} + +@layer components { + /* Sprungmarke fuer Screenreader und Tastaturnutzer. */ + .skip-link { + @apply absolute left-2 top-2 z-50 -translate-y-20 rounded-md bg-primary px-4 py-2 + text-primary-foreground transition-transform focus:translate-y-0; + } +} + +@media print { + /* Berichte werden ausgedruckt und dem Gericht vorgelegt. */ + .no-print { + display: none !important; + } + + body { + background: #fff; + color: #000; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a8fc5d1 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,81 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** Tailwind-Klassen zusammenfuehren, spaetere gewinnen. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +const DATUM_FORMAT = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', +}) + +const DATUM_ZEIT_FORMAT = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}) + +const EURO_FORMAT = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}) + +/** 22.08.2026 - oder ein Gedankenstrich, wenn nichts da ist. */ +export function formatDatum(datum: Date | string | null | undefined): string { + if (!datum) return '–' + const d = typeof datum === 'string' ? new Date(datum) : datum + if (Number.isNaN(d.getTime())) return '–' + return DATUM_FORMAT.format(d) +} + +/** 22.08.2026, 14:30 */ +export function formatDatumZeit(datum: Date | string | null | undefined): string { + if (!datum) return '–' + const d = typeof datum === 'string' ? new Date(datum) : datum + if (Number.isNaN(d.getTime())) return '–' + return DATUM_ZEIT_FORMAT.format(d) +} + +/** + * Betraege liegen in der Datenbank als Cent-Ganzzahl (BigInt), damit + * keine Rundungsfehler in die Rechnungslegung wandern. + */ +export function formatCent(cent: bigint | number | null | undefined): string { + if (cent === null || cent === undefined) return '–' + const zahl = typeof cent === 'bigint' ? Number(cent) : cent + return EURO_FORMAT.format(zahl / 100) +} + +/** Tage zwischen heute (Mitternacht) und einem Datum. Negativ = ueberfaellig. */ +export function tageBis(datum: Date | string): number { + const ziel = typeof datum === 'string' ? new Date(datum) : datum + const heute = new Date() + heute.setHours(0, 0, 0, 0) + const zielTag = new Date(ziel) + zielTag.setHours(0, 0, 0, 0) + return Math.round((zielTag.getTime() - heute.getTime()) / 86_400_000) +} + +/** "vor 3 Tagen", "in 2 Wochen" - fuer die Dashboard-Kacheln. */ +export function relativeTage(tage: number): string { + if (tage === 0) return 'heute' + if (tage === 1) return 'morgen' + if (tage === -1) return 'gestern' + if (tage < 0) return `vor ${Math.abs(tage)} Tagen` + return `in ${tage} Tagen` +} + +/** Aus "Erika Mustermann" wird "EM" - fuer Avatare in der Navigation. */ +export function initialen(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((teil) => teil[0]?.toUpperCase() ?? '') + .join('') +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..78a9a20 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,97 @@ +import type { Config } from 'tailwindcss' +import animate from 'tailwindcss-animate' + +const config: Config = { + darkMode: ['class'], + content: ['./src/**/*.{ts,tsx}'], + theme: { + container: { + center: true, + padding: '1rem', + screens: { '2xl': '1400px' }, + }, + extend: { + fontFamily: { + // Bewusst nur Systemschriften - keine externen Fonts, kein CDN. + sans: [ + 'system-ui', + '-apple-system', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + ], + }, + fontSize: { + // Grundschrift eine Stufe groesser als der Tailwind-Standard. + base: ['1.0625rem', { lineHeight: '1.6' }], + lg: ['1.1875rem', { lineHeight: '1.6' }], + }, + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + // Ampelfarben fuer Fristen - als Token, damit sie ueberall gleich sind. + ampel: { + rot: 'hsl(var(--ampel-rot))', + orange: 'hsl(var(--ampel-orange))', + gelb: 'hsl(var(--ampel-gelb))', + gruen: 'hsl(var(--ampel-gruen))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [animate], +} + +export default config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fce6483 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": 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"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b5a520e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})