From 63352272b85f4ac28cdf2cfc9403a49e925142e7 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 22 Aug 2026 02:22:36 +0200 Subject: [PATCH] fix: Navigationsstruktur aus der Client-Komponente herausloesen Das Server-Layout filtert die Navigation danach, welche Module der angemeldete Benutzer sehen darf. Es importierte NAV_STRUKTUR aber aus einer Datei mit "use client" - dort kommt auf dem Server kein Wert an, sondern nur ein Verweis. NAV_STRUKTUR.filter war entsprechend keine Funktion, und jede Fachseite lief in einen Serverfehler. Zweiter Fehler in derselben Zeile: die Eintraege trugen die Icons als React-Komponenten. Die lassen sich nicht ueber die Server/Client-Grenze reichen. Beides behoben, indem die Struktur nach src/lib/navigation-struktur.ts wandert und dort nur noch ein Kuerzel je Icon steht. Die Client- Komponente uebersetzt das Kuerzel. Die Wiederausfuhr aus navigation.tsx wurde bewusst weggelassen, sonst stellt sich dieselbe Falle wieder auf. Dazu scripts/screenshots.mjs: meldet sich mit dem Entwicklungszugang an und fotografiert Seiten am Schreibtisch und am Handy, hell und dunkel. Etappe: 1 Phase: Fundament --- scripts/screenshots.mjs | 150 +++++++++++++++++++++++++++++++++ src/app/(app)/layout.tsx | 2 +- src/components/navigation.tsx | 54 ++++++------ src/lib/navigation-struktur.ts | 58 +++++++++++++ 4 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 scripts/screenshots.mjs create mode 100644 src/lib/navigation-struktur.ts diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs new file mode 100644 index 0000000..70ddb82 --- /dev/null +++ b/scripts/screenshots.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Bildschirmfotos der Anwendung erzeugen. + * + * node scripts/screenshots.mjs [pfad ...] + * + * Meldet sich mit dem Entwicklungszugang an (SEED_DEV_TOTP_SECRET aus der + * .env) und fotografiert die angegebenen Seiten - jeweils am Schreibtisch + * und am Handy, hell und dunkel. Die Bilder landen unter ./screenshots. + * + * Nur fuer die Entwicklung gedacht: das Skript setzt voraus, dass der + * zweite Faktor mit einem bekannten Geheimnis vorbelegt ist. + */ +import { chromium } from '@playwright/test' +import { TOTP, Secret } from 'otpauth' +import { mkdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const BASIS = process.env.SCREENSHOT_BASIS ?? 'http://localhost:3000' +const AUSGABE = 'screenshots' + +async function envLesen() { + const roh = await readFile('.env', 'utf8') + const werte = {} + for (const zeile of roh.split(/\r?\n/)) { + const treffer = zeile.match(/^([A-Z0-9_]+)=(.*)$/) + if (treffer) werte[treffer[1]] = treffer[2].replace(/^"|"$/g, '') + } + return werte +} + +function code(secret, kennung) { + return new TOTP({ + issuer: 'Betreuungs-Cockpit', + label: kennung, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secret), + }).generate() +} + +const GERAETE = [ + { name: 'schreibtisch', viewport: { width: 1440, height: 960 }, mobil: false }, + { name: 'handy', viewport: { width: 390, height: 844 }, mobil: true }, +] + +async function main() { + const env = await envLesen() + const email = env.SEED_ADMIN_EMAIL + const passwort = env.SEED_ADMIN_PASSWORT + const secret = env.SEED_DEV_TOTP_SECRET + + if (!email || !passwort || !secret) { + console.error( + 'In der .env fehlen SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORT oder SEED_DEV_TOTP_SECRET.', + ) + process.exit(1) + } + + // Git Bash unter Windows wandelt "/heute" in einen Windows-Pfad um. + // Deshalb wird hier nur der letzte Abschnitt genommen und ein Slash + // vorangestellt - "heute" und "/heute" fuehren beide zum Ziel. + const pfade = process.argv.slice(2).map((roh) => { + // Aus "C:/Program Files/Git/heute" wird wieder "/heute". + const abschnitte = roh.split(/[\\/]+/).filter(Boolean) + const gefunden = abschnitte.findIndex((teil) => teil.toLowerCase() === 'git') + const rest = gefunden >= 0 ? abschnitte.slice(gefunden + 1) : abschnitte + return `/${rest.join('/')}` + }) + if (pfade.length === 0) { + console.error('Bitte mindestens einen Pfad angeben, z.B. /heute /fristen') + process.exit(1) + } + + await mkdir(AUSGABE, { recursive: true }) + const browser = await chromium.launch() + + for (const geraet of GERAETE) { + for (const thema of ['hell', 'dunkel']) { + const kontext = await browser.newContext({ + viewport: geraet.viewport, + isMobile: geraet.mobil, + hasTouch: geraet.mobil, + deviceScaleFactor: 2, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + colorScheme: thema === 'dunkel' ? 'dark' : 'light', + }) + + // Das Farbschema merkt sich die Anwendung im localStorage. + await kontext.addInitScript((wahl) => { + try { + localStorage.setItem('bc-thema', wahl) + } catch { + /* egal */ + } + }, thema) + + const seite = await kontext.newPage() + + // --- Anmelden ------------------------------------------------------ + await seite.goto(`${BASIS}/anmelden`, { waitUntil: 'networkidle' }) + + if (pfade.includes('/anmelden')) { + await seite.screenshot({ + path: path.join(AUSGABE, `anmelden-${geraet.name}-${thema}.png`), + fullPage: true, + }) + } + + await seite.fill('input[name="email"]', email) + await seite.fill('input[name="passwort"]', passwort) + await seite.fill('input[name="code"]', code(secret, email)) + await Promise.all([ + seite.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 30_000 }), + seite.click('button[type="submit"]'), + ]) + + // --- Seiten fotografieren ------------------------------------------- + for (const pfad of pfade) { + if (pfad === '/anmelden') continue + + const name = pfad.replace(/^\//, '').replace(/\//g, '-') || 'start' + try { + await seite.goto(`${BASIS}${pfad}`, { waitUntil: 'networkidle', timeout: 45_000 }) + // Kurz warten, damit Schriften und Übergänge sitzen. + await seite.waitForTimeout(600) + await seite.screenshot({ + path: path.join(AUSGABE, `${name}-${geraet.name}-${thema}.png`), + fullPage: true, + }) + console.log(` ${name}-${geraet.name}-${thema}.png`) + } catch (fehler) { + console.warn(` ${pfad} (${geraet.name}/${thema}) fehlgeschlagen: ${fehler.message}`) + } + } + + await kontext.close() + } + } + + await browser.close() + console.log(`\nFertig. Bilder liegen in ./${AUSGABE}`) +} + +main().catch((fehler) => { + console.error(fehler) + process.exit(1) +}) diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 14d66a4..5061f86 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation' import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle' import { BenutzerMenue } from '@/components/benutzer-menue' import { BetreuteAuswahl } from '@/components/betreute-auswahl' -import { NAV_STRUKTUR, type NavEintrag } from '@/components/navigation' +import { NAV_STRUKTUR, type NavEintrag } from '@/lib/navigation-struktur' import { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung' import { darf } from '@/lib/auth/rollen' import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen' diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 8118347..7035b27 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -16,42 +16,32 @@ import { Shield, Users, } from 'lucide-react' -import type { Modul } from '@prisma/client' +import type { IconName, NavEintrag } from '@/lib/navigation-struktur' import { cn } from '@/lib/utils' /** * Hauptnavigation. * - * Reihenfolge nach Haeufigkeit im Alltag, nicht nach fachlicher Systematik: - * Heute steht oben, weil ich morgens dort anfange. Berichte stehen unten, - * weil ich sie zweimal im Jahr brauche. + * Die Struktur selbst liegt in src/lib/navigation-struktur.ts, damit das + * Server-Layout sie filtern kann. Hier wird nur aus dem Kuerzel das Icon - + * React-Komponenten lassen sich nicht ueber die Server/Client-Grenze + * reichen. */ -export type NavEintrag = { - titel: string - href: string - icon: React.ComponentType<{ className?: string }> - modul: Modul - /** Zaehler, der als kleine Blase erscheint (z.B. offene Fristen). */ - zaehler?: number - /** Faerbt die Blase rot statt neutral. */ - dringend?: boolean +const ICONS: Record> = { + heute: Home, + stammdaten: IdCard, + fristen: CalendarClock, + vorgaenge: FolderKanban, + dokumente: FileText, + finanzen: Banknote, + sozialhilfe: Landmark, + immobilie: Building2, + vertraege: Shield, + pflege: HeartPulse, + kontakte: Users, + berichte: ScrollText, } -export const NAV_STRUKTUR: Omit[] = [ - { titel: 'Heute', href: '/heute', icon: Home, modul: 'STAMMDATEN' }, - { titel: 'Stammdaten', href: '/stammdaten', icon: IdCard, modul: 'STAMMDATEN' }, - { titel: 'Fristen', href: '/fristen', icon: CalendarClock, modul: 'FRISTEN' }, - { titel: 'Vorgänge', href: '/vorgaenge', icon: FolderKanban, modul: 'VORGAENGE' }, - { titel: 'Dokumente', href: '/dokumente', icon: FileText, modul: 'DOKUMENTE' }, - { titel: 'Vermögen & Kasse', href: '/finanzen', icon: Banknote, modul: 'FINANZEN' }, - { titel: 'Sozialhilfe', href: '/sozialhilfe', icon: Landmark, modul: 'SOZIALHILFE' }, - { titel: 'Immobilie', href: '/immobilie', icon: Building2, modul: 'IMMOBILIE' }, - { titel: 'Verträge', href: '/vertraege', icon: Shield, modul: 'VERTRAEGE' }, - { titel: 'Heim & Pflege', href: '/pflege', icon: HeartPulse, modul: 'GESUNDHEIT' }, - { titel: 'Kontakte', href: '/kontakte', icon: Users, modul: 'KONTAKTE' }, - { titel: 'Berichte', href: '/berichte', icon: ScrollText, modul: 'BERICHTE' }, -] - export function Navigation({ eintraege, beimKlick, @@ -68,7 +58,7 @@ export function Navigation({ {eintraege.map((eintrag) => { // "/fristen/neu" soll "Fristen" ebenfalls hervorheben. const aktiv = pfad === eintrag.href || pfad.startsWith(`${eintrag.href}/`) - const Icon = eintrag.icon + const Icon = ICONS[eintrag.icon] return ( ) } + +// Bewusst KEINE Wiederausfuhr von NAV_STRUKTUR: Diese Datei ist eine +// Client-Komponente. Was von hier importiert wird, kommt auf dem Server +// nur als Verweis an - nicht als echter Wert. Die Struktur holt sich das +// Layout direkt aus @/lib/navigation-struktur. +export type { NavEintrag } from '@/lib/navigation-struktur' diff --git a/src/lib/navigation-struktur.ts b/src/lib/navigation-struktur.ts new file mode 100644 index 0000000..193fb38 --- /dev/null +++ b/src/lib/navigation-struktur.ts @@ -0,0 +1,58 @@ +import type { Modul } from '@prisma/client' + +/** + * Aufbau der Hauptnavigation - bewusst OHNE "use client" und ohne echte + * Icon-Komponenten. + * + * Grund: Diese Liste wird im Server-Layout gefiltert (welche Module darf + * der angemeldete Benutzer sehen?) und danach an eine Client-Komponente + * uebergeben. Ueber diese Grenze laesst sich nur Serialisierbares + * reichen - eine React-Komponente wie ein Icon gehoert nicht dazu. + * Deshalb steht hier nur ein Kuerzel, das die Client-Komponente in das + * passende Icon uebersetzt. + */ + +export type IconName = + | 'heute' + | 'stammdaten' + | 'fristen' + | 'vorgaenge' + | 'dokumente' + | 'finanzen' + | 'sozialhilfe' + | 'immobilie' + | 'vertraege' + | 'pflege' + | 'kontakte' + | 'berichte' + +export type NavEintrag = { + titel: string + href: string + icon: IconName + modul: Modul + /** Zaehler, der als kleine Blase erscheint (z.B. ueberfaellige Fristen). */ + zaehler?: number + /** Faerbt die Blase rot statt neutral. */ + dringend?: boolean +} + +/** + * Reihenfolge nach Haeufigkeit im Alltag, nicht nach fachlicher Systematik: + * "Heute" steht oben, weil dort der Tag anfaengt. "Berichte" steht unten, + * weil sie zweimal im Jahr gebraucht werden. + */ +export const NAV_STRUKTUR: Omit[] = [ + { titel: 'Heute', href: '/heute', icon: 'heute', modul: 'STAMMDATEN' }, + { titel: 'Stammdaten', href: '/stammdaten', icon: 'stammdaten', modul: 'STAMMDATEN' }, + { titel: 'Fristen', href: '/fristen', icon: 'fristen', modul: 'FRISTEN' }, + { titel: 'Vorgänge', href: '/vorgaenge', icon: 'vorgaenge', modul: 'VORGAENGE' }, + { titel: 'Dokumente', href: '/dokumente', icon: 'dokumente', modul: 'DOKUMENTE' }, + { titel: 'Vermögen & Kasse', href: '/finanzen', icon: 'finanzen', modul: 'FINANZEN' }, + { titel: 'Sozialhilfe', href: '/sozialhilfe', icon: 'sozialhilfe', modul: 'SOZIALHILFE' }, + { titel: 'Immobilie', href: '/immobilie', icon: 'immobilie', modul: 'IMMOBILIE' }, + { titel: 'Verträge', href: '/vertraege', icon: 'vertraege', modul: 'VERTRAEGE' }, + { titel: 'Heim & Pflege', href: '/pflege', icon: 'pflege', modul: 'GESUNDHEIT' }, + { titel: 'Kontakte', href: '/kontakte', icon: 'kontakte', modul: 'KONTAKTE' }, + { titel: 'Berichte', href: '/berichte', icon: 'berichte', modul: 'BERICHTE' }, +]