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
This commit is contained in:
Florian Wilde
2026-08-22 01:31:17 +02:00
commit cc72f5efd7
15 changed files with 638 additions and 0 deletions
+14
View File
@@ -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
+63
View File
@@ -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 <cockpit@example.de>"
# --- 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
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+41
View File
@@ -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*
+11
View File
@@ -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.
+17
View File
@@ -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"
}
}
+36
View File
@@ -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
+66
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+26
View File
@@ -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.
`)
+139
View File
@@ -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;
}
}
+81
View File
@@ -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('')
}
+97
View File
@@ -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
+24
View File
@@ -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"]
}
+14
View File
@@ -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)),
},
},
})