Files
property-match/src/components/layout/GlobalNavMenu.tsx
T
Benjamin SutterandClaude Opus 5 4349641ee9 fix(startseite): durchgehendes Basel-Panorama, Kreise ohne Stühle
Vier Korrekturen aus der Nachprüfung.

**Naht in der linken Fensterfront.** Sie stammte von zwei getrennten
Fotoflächen — vorne und links. Eine Stossstelle lässt sich nicht wegblenden;
sie verschwindet nur, wenn es keine zwei Bilder mehr gibt. Die Kulisse ist
jetzt eine gekrümmte Fläche mit einer einzigen Panoramadatei: auf einem
durchgehenden Zylinder ist eine Naht geometrisch unmöglich.

Das Panorama entsteht einmalig aus den beiden Referenzfotos
(`scripts/build-basel-panorama.mjs`), jede Quelle genau einmal — kein Kacheln,
kein Spiegeln. Das gute Motiv vom Tischende bleibt in Ausschnitt und Farbe
unverändert und steht weiterhin in der Frontscheibe; die Panoramaaufnahme setzt
es nach links fort, helligkeitsangeglichen und über 320 Pixel überblendet. Aus
der Fortsetzung wird nur der Abschnitt östlich des Münsters verwendet — beide
Vorlagen zeigen es, nebeneinander stünde es zweimal im Bild. Himmel und Wasser
sind aus den Randzeilen gestreckt, damit oben und unten keine Bildgrenze bleibt.

**Stühle und Körper entfernt.** Sitzmöbel, Rümpfe, Schultern, Hälse, Arme und
Hinterköpfe sind ersatzlos weg. Die Agenten bestehen nur noch aus ihrem runden
Porträt mit weissem Ring.

**Positionen neu.** Ferdi und Bruno sind die direkten Nachbarn links und rechts
vorne, die übrigen gestaffelt dahinter, Thomas unverändert vis-à-vis. Die
Kreise liegen deutlich über der Blickachse: lagen sie auf Augenhöhe,
projizierten alle fünf auf fast dieselbe Bildhöhe und die hinteren
verschwanden hinter den vorderen — seitliches Auseinanderziehen half dagegen
nicht, weil ein tieferer Platz in der Perspektive zur Mitte wandert.

Dazu ein echter Fehler, der dabei auffiel: die grosszügigen Trefferquader der
vorderen Kreise fingen die Strahlen der hinteren ab, ein Klick auf Livia landete
bei Ferdi. Die Trefferfläche ist jetzt eine Kugel knapp um den sichtbaren Kreis.

**Bildschärfe.** Die Textur wird auf einen mittigen quadratischen Ausschnitt
begrenzt — ohne das zieht die Kreisgeometrie ein liegendes Foto in die Breite,
was Gesichter verzerrt und Schärfe kostet. Dazu anisotrope Filterung und
Mipmaps. Höher aufgelöste Originale der fünf Agenten waren nicht auffindbar:
die Porträts in `Agenten_Verwaltung_Fotos.html` liegen zwar in 256×256 vor,
gehören aber zu einer anderen Belegschaft — geprüft und verworfen, statt Gesichter
zu vertauschen.

**Ortsangabe** unten rechts entfernt. **Menü** liegt bündig an der rechten
Fensterkante: der Spalt kam vom Anker am eingerückten Menüsymbol, jetzt ist das
Klappmenü an eine Bildschirmstelle gebunden.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925). Im
Browser 1280 und 1600 px: sechs Beschriftungen ohne Überlappung, sechs Kreise
ohne Überlappung, alle sechs per Maus auf der richtigen Subpage, Menü bündig
ohne waagrechten Scrollbalken, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:32:58 +02:00

237 lines
8.4 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Box, ButtonBase, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team'
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
const ROLE_LABELS: Record<string, string> = {
PROPERTY_MANAGER: 'Property Manager',
DEMAND_USER: 'Demand User',
}
/**
* Globale Navigation in der Kopfzeile (Runde 6, §11–§16).
*
* Sie ersetzt die entfernte Seitenleiste und das frühere Profilsymbol. Ein
* einziges Menüsymbol neben der Glocke öffnet ein Klappmenü, das rechts an der
* Kopfzeile ausgerichtet nach unten aufgeht.
*
* Aufbau: «Startseite», «Meine Agenten» mit allen Agenten als eingerückte,
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
* die vorher im Profilmenü lagen.
*
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
* Schritt 7).
*
* `Popover` statt `Menu`: Ein `Menu` erwartet gleichrangige `MenuItem`-Kinder
* und übernimmt Tastaturnavigation über eine flache Liste. Hier gibt es zwei
* Ebenen mit unterschiedlicher Ausrichtung — das Popover lässt diese Struktur
* zu und bringt Klick-ausserhalb und Escape von sich aus mit.
*/
export function GlobalNavMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
// Beim Öffnen festgehaltene Ankerstelle: oben unter der Kopfzeile, rechts
// exakt an der Fensterkante.
const [ankerStelle, setAnkerStelle] = useState({ top: 64, left: 0 })
const { currentUser, logout } = useSessionStore()
const navigate = useNavigate()
const showToast = useToastStore(s => s.showToast)
const open = !!anchorEl
const close = () => setAnchorEl(null)
/** Menüpunkt und Ziel gehören zusammen: erst schliessen, dann navigieren. */
const go = (path: string) => {
close()
navigate(path)
}
const handleLogout = () => {
close()
logout()
navigate('/auth/login')
}
return (
<>
<Tooltip title="Menü">
<IconButton
size="small"
onClick={e => {
if (open) { setAnchorEl(null); return }
const b = e.currentTarget.getBoundingClientRect()
setAnkerStelle({ top: b.bottom + 8, left: document.documentElement.clientWidth })
setAnchorEl(e.currentTarget)
}}
aria-label="Menü öffnen"
aria-haspopup="true"
aria-expanded={open}
sx={{ color: DS_TEXT.primary }}
>
<LayoutGrid size={20} />
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
/*
* An eine Bildschirmstelle statt an das Symbol gebunden: das Symbol liegt
* um den Innenabstand der Kopfzeile eingerückt, und ein daran
* ausgerichtetes Menü erbte genau diesen Abstand als Spalt zur rechten
* Fensterkante (§5.1). `marginThreshold={0}` hebt zusätzlich den
* Sicherheitsabstand auf, den MUI sonst zum Fensterrand hält.
* Gerechnet wird mit `clientWidth` statt `innerWidth`, also ohne den
* Scrollbalken — sonst entstünde ein waagrechter Scrollbalken.
*/
anchorReference="anchorPosition"
anchorPosition={ankerStelle}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
marginThreshold={0}
slotProps={{
paper: {
sx: {
width: 320,
borderRadius: 2,
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
overflow: 'hidden',
},
},
}}
>
<MenuSection>
<MainItem label={HOME_NAV_LABEL} onClick={() => go(ROUTES.SUPPLY.PROPERTIES)} />
</MenuSection>
<Divider />
<MenuSection>
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
{AGENT_WORKSPACES.map(agent => (
<SubItem key={agent.id} onClick={() => go(agent.path)}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
{agent.name}
</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: DS_TEXT.secondary }}>
{agent.role}
</Typography>
<AgentAvatar agent={agent} size={26} />
</SubItem>
))}
</MenuSection>
<Divider />
<MenuSection>
<MainItem label="Profil" onClick={() => showProfileHint(showToast, close)} />
{currentUser && (
<Typography
sx={{
px: 2, pb: 0.75, textAlign: 'right',
fontSize: '0.6875rem', color: DS_TEXT.muted,
}}
>
{currentUser.name} · {ROLE_LABELS[currentUser.role] ?? currentUser.role}
{currentUser.organizationName ? ` · ${currentUser.organizationName}` : ''}
</Typography>
)}
<SubItem onClick={() => showProfileHint(showToast, close)}>
<ProfileLabel icon={<User size={15} />} label="Profil" />
</SubItem>
<SubItem onClick={() => { close(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
<ProfileLabel icon={<Settings size={15} />} label="Einstellungen" />
</SubItem>
<SubItem
onClick={() => {
// Bestehende Logik: die Tour zeigt sich erneut, sobald die Merkung
// entfernt ist. Nicht neu gebaut (§16).
close()
localStorage.removeItem('property_match_welcomed')
window.location.reload()
}}
>
<ProfileLabel icon={<HelpCircle size={15} />} label="Produkttour starten" />
</SubItem>
<SubItem onClick={handleLogout}>
<ProfileLabel icon={<LogOut size={15} />} label="Abmelden" />
</SubItem>
</MenuSection>
</Popover>
</>
)
}
function showProfileHint(showToast: (m: string, t: 'info') => void, close: () => void) {
close()
showToast('Profilseite ist in Kürze verfügbar.', 'info')
}
function MenuSection({ children }: { children: React.ReactNode }) {
return <Box sx={{ py: 1 }}>{children}</Box>
}
/** Hauptpunkt: linksbündig, kräftig — er trägt die Hierarchie. */
function MainItem({ label, onClick }: { label: string; onClick: () => void }) {
return (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
px: 2, py: 1,
'&:hover': { bgcolor: DS_SLATE[50] },
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
}}
>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_BRAND.main }}>
{label}
</Typography>
</ButtonBase>
)
}
/**
* Unterpunkt: kleiner, eingerückt und am rechten Rand ausgerichtet (§14/§16).
* Die Rechtsausrichtung ist die Vorgabe — sie trennt die Ebenen auch ohne
* Einrückungslinien sichtbar voneinander.
*/
function SubItem({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 1,
pl: 4, pr: 2, py: 0.75,
textAlign: 'right',
'&:hover': { bgcolor: DS_SLATE[50] },
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
}}
>
{children}
</ButtonBase>
)
}
function ProfileLabel({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<>
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.primary }}>{label}</Typography>
<Box sx={{ display: 'flex', color: DS_TEXT.secondary }}>{icon}</Box>
</>
)
}