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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
+41
@@ -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*
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
`)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user