feat(startseite): echter 3D-Meetingraum, Navigation in die Topbar

Anpassungsrunde 6. Die Kartendarstellung aus Runde 5 ist ersetzt: die
Startseite zeigt oben einen räumlichen Meetingraum aus der Sicht des
Betrachters, der selbst am Kopfende des Tisches sitzt.

**3D statt Kulisse.** Zwei Versuche mit CSS-Flächen sind daran gescheitert,
dass gezeichnete Wände neben fotografierten Porträts erkennbar gezeichnet
bleiben. Diese Runde nutzt three.js — die einzige neue Abhängigkeit, und §19
lässt sie ausdrücklich zu, wenn eine schlanke 3D-Lösung die Anforderung trägt.
Keine Engine darüber hinaus: kein React-Three-Fiber, keine Physik, keine
Shader, keine Post-Processing-Kette, keine Orbit-Steuerung.

Der Raum: Boden, Decke, rechts eine geschlossene Wand mit Eichenpaneel und
einem schmalen Streifen im Markenblau, links und gegenüber bodentiefe
Glasfassaden mit Pfosten. Draussen tragen die beiden Referenzfotos aus dem
Auftrag die Basler Aussicht — vorne Rhein, Altstadt und Münster, links der
Rheinverlauf mit der Wettsteinbrücke. Die Wasserlinie beider Kulissen liegt auf
derselben Welthöhe, damit der Blick nach vorne und nach links zusammengehört
und nicht wie zwei zusammengesetzte Bilder wirkt (§4).

Am Tisch sitzen alle fünf bestehenden Agenten mit ihren bestehenden Porträts,
Namen, Funktionen und Zielseiten — alles aus `AGENT_WORKSPACES`, damit nichts
doppelt gepflegt wird (§24, Schritt 7). Neu ist Thomas, Personalverwalter,
vis-à-vis am gegenüberliegenden Kopfende; sein Klick führt in die bestehende
Ansicht «Meine Agenten».

Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für
eine feste Szene verschwendete Rechenzeit (§21). Die Kamera folgt der Maus um
wenige Zentimeter, mehr nicht (§20).

Die Beschriftungen sind HTML über dem Bild, nicht Textur in der Szene: als
Textur wären sie bei Tiefe unlesbar. Ein Entzerrungsdurchgang nach der
Projektion hebt Plaketten so weit an, bis keine mehr überlappt — gerechnet und
nicht handverlesen, damit es auf jeder Fenstergrösse hält. Bei Hover kommt eine
Zeile mit der aktuellen Auslastung dazu; ruhend bleiben es Name und Funktion.

**Navigation.** Die linke Seitenleiste ist gelöscht, nicht ausgeblendet: samt
Breitenreservierung, Einklapplogik und mobilem Drawer. Der Inhalt beginnt
überall bei x=0. An der Stelle des früheren Profilsymbols steht neben der
Glocke ein Menüsymbol; das Klappmenü öffnet rechts an der Kopfzeile nach unten
und führt Startseite, Meine Agenten samt aller Agenten als eingerückte,
rechtsbündige Unterpunkte, und Profil mit Profil, Einstellungen, Produkttour
und Abmelden. Alle Aktionen und Routen sind die bestehenden.

Entfallen sind die Kartenkomponenten aus Runde 5 samt Präsenzschicht.

Geprüft (§25): 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925).
Im Browser 430–2560 px: keine alte Sidebar, kein leerer Streifen links, sechs
Beschriftungen ohne Überlappung, alle sechs Agenten per Maus und per Tastatur
erreichbar und auf der richtigen Subpage, Menü öffnet und schliesst per Klick
und Escape, alle Menürouten korrekt, alle bestehenden Seiten laden, kein
horizontaler Overflow, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-12 18:00:00 +02:00
1 parent e55be64aeb
commit bf453d2b69
30 files changed
+1408 -1412

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
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)
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 => setAnchorEl(open ? null : 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}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
slotProps={{
paper: {
sx: {
mt: 1,
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>
</>
)
}