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
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'
|
|||||||
import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle'
|
import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle'
|
||||||
import { BenutzerMenue } from '@/components/benutzer-menue'
|
import { BenutzerMenue } from '@/components/benutzer-menue'
|
||||||
import { BetreuteAuswahl } from '@/components/betreute-auswahl'
|
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 { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung'
|
||||||
import { darf } from '@/lib/auth/rollen'
|
import { darf } from '@/lib/auth/rollen'
|
||||||
import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
||||||
|
|||||||
@@ -16,42 +16,32 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Modul } from '@prisma/client'
|
import type { IconName, NavEintrag } from '@/lib/navigation-struktur'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hauptnavigation.
|
* Hauptnavigation.
|
||||||
*
|
*
|
||||||
* Reihenfolge nach Haeufigkeit im Alltag, nicht nach fachlicher Systematik:
|
* Die Struktur selbst liegt in src/lib/navigation-struktur.ts, damit das
|
||||||
* Heute steht oben, weil ich morgens dort anfange. Berichte stehen unten,
|
* Server-Layout sie filtern kann. Hier wird nur aus dem Kuerzel das Icon -
|
||||||
* weil ich sie zweimal im Jahr brauche.
|
* React-Komponenten lassen sich nicht ueber die Server/Client-Grenze
|
||||||
|
* reichen.
|
||||||
*/
|
*/
|
||||||
export type NavEintrag = {
|
const ICONS: Record<IconName, React.ComponentType<{ className?: string }>> = {
|
||||||
titel: string
|
heute: Home,
|
||||||
href: string
|
stammdaten: IdCard,
|
||||||
icon: React.ComponentType<{ className?: string }>
|
fristen: CalendarClock,
|
||||||
modul: Modul
|
vorgaenge: FolderKanban,
|
||||||
/** Zaehler, der als kleine Blase erscheint (z.B. offene Fristen). */
|
dokumente: FileText,
|
||||||
zaehler?: number
|
finanzen: Banknote,
|
||||||
/** Faerbt die Blase rot statt neutral. */
|
sozialhilfe: Landmark,
|
||||||
dringend?: boolean
|
immobilie: Building2,
|
||||||
|
vertraege: Shield,
|
||||||
|
pflege: HeartPulse,
|
||||||
|
kontakte: Users,
|
||||||
|
berichte: ScrollText,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_STRUKTUR: Omit<NavEintrag, 'zaehler' | 'dringend'>[] = [
|
|
||||||
{ 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({
|
export function Navigation({
|
||||||
eintraege,
|
eintraege,
|
||||||
beimKlick,
|
beimKlick,
|
||||||
@@ -68,7 +58,7 @@ export function Navigation({
|
|||||||
{eintraege.map((eintrag) => {
|
{eintraege.map((eintrag) => {
|
||||||
// "/fristen/neu" soll "Fristen" ebenfalls hervorheben.
|
// "/fristen/neu" soll "Fristen" ebenfalls hervorheben.
|
||||||
const aktiv = pfad === eintrag.href || pfad.startsWith(`${eintrag.href}/`)
|
const aktiv = pfad === eintrag.href || pfad.startsWith(`${eintrag.href}/`)
|
||||||
const Icon = eintrag.icon
|
const Icon = ICONS[eintrag.icon]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -106,3 +96,9 @@ export function Navigation({
|
|||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
|||||||
@@ -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<NavEintrag, 'zaehler' | 'dringend'>[] = [
|
||||||
|
{ 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' },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user