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
+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('')
}