diff --git a/package-lock.json b/package-lock.json index 190de1f..22bcb7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-dom": "^19.2.6", "react-leaflet": "^5.0.0", "react-router": "^7.15.0", + "three": "^0.185.1", "zod": "^3.25.17", "zustand": "^5.0.5" }, @@ -34,6 +35,7 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", @@ -516,6 +518,13 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1935,6 +1944,13 @@ } } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2060,6 +2076,35 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -3219,6 +3264,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4092,6 +4144,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4805,6 +4864,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 14af4cc..3c472c8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-dom": "^19.2.6", "react-leaflet": "^5.0.0", "react-router": "^7.15.0", + "three": "^0.185.1", "zod": "^3.25.17", "zustand": "^5.0.5" }, @@ -40,6 +41,7 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", diff --git a/src/assets/staff/HERKUNFT.md b/src/assets/staff/HERKUNFT.md index 90059ec..28e0f8d 100644 --- a/src/assets/staff/HERKUNFT.md +++ b/src/assets/staff/HERKUNFT.md @@ -1,14 +1,15 @@ # Bildherkunft -| Datei | Quelle | Lizenz | +| Datei | Inhalt | Herkunft | |---|---|---| -| `buero.jpg` | Unsplash — Craig Lovelidge, «An empty conference room with desks and chairs» (The Mark, Rotterdam) | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig | +| `basel-front.jpg` | Blick über den Rhein auf Grossbasler Altstadt und Münster, links die Wettsteinbrücke | Referenzbild 1 aus dem Auftrag (Anpassungsrunde 5/6) | +| `basel-left.jpg` | Rheinperspektive mit Altstadt- und Münster-Silhouette, Panoramaformat | Referenzbild 2 aus dem Auftrag (Anpassungsrunde 5/6) | -Direktbezug: `https://images.unsplash.com/photo-1637665599155-abfcf9fc3f78` +Beide Bilder sind die dem Auftrag beigelegten Referenzfotos. Sie liegen im +3D-Meetingraum als Fernkulisse **aussen vor den Glasfassaden** — `basel-front` +hinter der gegenüberliegenden Front, `basel-left` entlang der linken Front. -Das Bild dient als abgedunkelte Umgebung hinter den Personenkarten der -Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts` -nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen. - -**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme -ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft. +Weil beide Aufnahmen dieselbe Rheinseite von derselben Uferseite zeigen, ergeben +sie zusammen einen zusammenhängenden Stadtverlauf: die Wasserlinie beider +Kulissen liegt auf derselben Welthöhe, damit der Blick nach vorne und der Blick +nach links nicht wie zwei zusammengesetzte Fotos wirken (Runde 6, §4). diff --git a/src/assets/staff/basel-front.jpg b/src/assets/staff/basel-front.jpg new file mode 100644 index 0000000..92889dc Binary files /dev/null and b/src/assets/staff/basel-front.jpg differ diff --git a/src/assets/staff/basel-left.jpg b/src/assets/staff/basel-left.jpg new file mode 100644 index 0000000..fff8aa5 Binary files /dev/null and b/src/assets/staff/basel-left.jpg differ diff --git a/src/assets/staff/buero.jpg b/src/assets/staff/buero.jpg deleted file mode 100644 index 65eb6f2..0000000 Binary files a/src/assets/staff/buero.jpg and /dev/null differ diff --git a/src/assets/team/HERKUNFT.md b/src/assets/team/HERKUNFT.md new file mode 100644 index 0000000..a8d7c5f --- /dev/null +++ b/src/assets/team/HERKUNFT.md @@ -0,0 +1,12 @@ +# Bildherkunft — Porträts + +Die Porträts der fünf Agenten stammen aus dem Konzeptdokument und sind +fachliche Vorgabe. + +| Datei | Herkunft | Lizenz | +|---|---|---| +| `thomas.jpg` | Unsplash, Porträt eines Mannes in Business-Kleidung (`photo-1560250097-0b93528c311a`), quadratisch auf das Gesicht beschnitten | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig | + +Thomas ist in Anpassungsrunde 6 neu dazugekommen und hatte kein Porträt aus dem +Konzeptdokument. Der Kopf eines bestehenden Mitarbeitenden wurde bewusst nicht +zweitverwendet — jeder Kopf gehört genau einer Person. diff --git a/src/assets/team/thomas.jpg b/src/assets/team/thomas.jpg new file mode 100644 index 0000000..9584b2b Binary files /dev/null and b/src/assets/team/thomas.jpg differ diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 29e9938..955e761 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,46 +1,35 @@ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router' -import { Box, Drawer, useTheme, useMediaQuery } from '@mui/material' +import { Box } from '@mui/material' import { useLayoutStore } from '../../stores/layoutStore' import { useSessionStore } from '../../stores/sessionStore' import { WelcomeDialog } from '../onboarding/WelcomeDialog' import { RightContextPanel } from './RightContextPanel' import { ToastProvider } from '../ui' -import { Sidebar } from './AppShellSidebar' import { TopBar } from './AppShellTopBar' -import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig' -import { WorkspaceType } from '../../domain/enums' - -// --------------------------------------------------------------------------- -// AppShell -// --------------------------------------------------------------------------- +import { WORKSPACE_CONFIG, getWorkspaceFromPath } from './appShellConfig' +/** + * Rahmen der Anwendung (Runde 6, §10). + * + * Die permanente Navigationsleiste links ist entfallen — samt Breitenreservierung, + * Einklapplogik und mobilem Drawer. Die Navigation liegt jetzt vollständig im + * Menü der Kopfzeile (§11–§14). Der Inhaltsbereich beginnt dadurch am linken + * Fensterrand; es bleibt kein leerer Streifen zurück, weil die Sidebar nicht + * ausgeblendet, sondern gelöscht ist. + * + * Geblieben sind die beiden Effekte, die nichts mit der Navigation zu tun haben: + * der Abgleich des Arbeitsbereichs mit der URL und die Umleitung, wenn ein + * Rollenwechsel den aktuellen Arbeitsbereich verbietet. + */ export function AppShell() { const activeWorkspace = useLayoutStore(s => s.activeWorkspace) - const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed) const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace) - const toggleSidebar = useLayoutStore(s => s.toggleSidebar) - const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed) const { currentUser } = useSessionStore() const navigate = useNavigate() const location = useLocation() - const theme = useTheme() - const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - const isCompact = useMediaQuery(theme.breakpoints.down('lg')) - const [mobileOpen, setMobileOpen] = useState(false) - // Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es - // nach dem Routenwechsel per Effekt zu schliessen, hängt der Zustand am Pfad: - // ein Wechsel erzeugt einen neuen Schlüssel und damit einen frischen Zustand. - const [openForPath, setOpenForPath] = useState(location.pathname) - const isMobileOpen = mobileOpen && openForPath === location.pathname - const openMobileMenu = () => { setOpenForPath(location.pathname); setMobileOpen(true) } - const closeMobileMenu = () => setMobileOpen(false) - - // Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account - useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed]) - - // Sync active workspace with URL + // Arbeitsbereich am Pfad ausrichten useEffect(() => { const detected = getWorkspaceFromPath(location.pathname) if (detected && detected !== activeWorkspace) { @@ -48,8 +37,7 @@ export function AppShell() { } }, [location.pathname, activeWorkspace, setActiveWorkspace]) - // When the user changes (role switch), redirect to their first allowed workspace - // if the current workspace is no longer permitted + // Nach einem Rollenwechsel auf den ersten erlaubten Arbeitsbereich umleiten useEffect(() => { if (!currentUser) return const allowed = currentUser.allowedWorkspaces @@ -64,60 +52,15 @@ export function AppShell() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentUser]) - const handleWorkspaceClick = (workspace: WorkspaceType) => { - setActiveWorkspace(workspace) - navigate(WORKSPACE_CONFIG[workspace].firstPath) - } - - const userName = currentUser?.name ?? 'User' - const orgName = currentUser?.organizationName ?? '' - const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER - - const sidebarContent = ( - - ) - return ( - - {/* Desktop: permanent sidebar */} - {!isMobile && sidebarContent} + + - {/* Mobile: temporary drawer */} - {isMobile && ( - - {sidebarContent} - - )} - - - - - - - - - + + + diff --git a/src/components/layout/AppShellSidebar.tsx b/src/components/layout/AppShellSidebar.tsx deleted file mode 100644 index d06cef1..0000000 --- a/src/components/layout/AppShellSidebar.tsx +++ /dev/null @@ -1,400 +0,0 @@ -import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material' -import { ChevronLeft, ChevronRight } from 'lucide-react' -import { NavLink, useLocation } from 'react-router' -import { WorkspaceType } from '../../domain/enums' -import { useCompareStore } from '../../stores/compareStore' -import { AgentAvatar } from '../team' -import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig' -import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' - -// --------------------------------------------------------------------------- -// Visual constants -// --------------------------------------------------------------------------- - -export const SIDEBAR_BG = '#0f1923' -export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)' -export const TEXT_MUTED = '#94a3b8' -export const TEXT_WHITE = '#ffffff' -export const NAV_ACTIVE_BG = 'transparent' -export const NAV_ACTIVE_ACCENT = '#b8975a' -export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SidebarProps { - collapsed: boolean - activeWorkspace: WorkspaceType - allowedWorkspaces: WorkspaceType[] - onWorkspaceClick: (workspace: WorkspaceType) => void - onToggle: () => void - onClose?: () => void - userName: string - orgName: string -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function Sidebar({ - collapsed, - activeWorkspace, - allowedWorkspaces, - onWorkspaceClick, - onToggle, - onClose, - userName, - orgName, -}: SidebarProps) { - const config = WORKSPACE_CONFIG[activeWorkspace] - const compareCount = useCompareStore(s => s.compareItems.length) - const { pathname } = useLocation() - const width = collapsed ? 60 : 264 - const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws)) - - return ( - - {/* Logo area */} - - {collapsed ? ( - - PM - - ) : ( - - - Property - - - Match - - - )} - - - {/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */} - {visibleWorkspaces.length > 1 && - {visibleWorkspaces.map((ws, idx) => { - const wsConfig = WORKSPACE_CONFIG[ws] - const Icon = wsConfig.icon - const isActive = ws === activeWorkspace - - const tabContent = ( - { onWorkspaceClick(ws); onClose?.() }} - sx={{ - display: 'flex', - flexDirection: collapsed ? 'row' : 'column', - alignItems: 'center', - justifyContent: 'center', - gap: collapsed ? 0 : 0.5, - py: collapsed ? 1 : 1.25, - cursor: 'pointer', - backgroundColor: isActive ? wsConfig.chipColor : 'transparent', - borderRight: !collapsed && idx < visibleWorkspaces.length - 1 - ? `1px solid ${DIVIDER_COLOR}` - : 'none', - transition: 'background-color 0.15s ease', - '&:hover': { - backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG, - }, - }} - > - - {!collapsed && ( - - {wsConfig.label} - - )} - - ) - - return collapsed ? ( - - {tabContent} - - ) : ( - {tabContent} - ) - })} - } - - {/* Nav items */} - - {config.navItems.map((item) => { - const Icon = item.icon - - const navContent = ( - onClose?.()} - > - {({ isActive }) => ( - - - {!collapsed && ( - - - {item.label} - - {item.path === '/demand/compare' && compareCount > 0 && ( - - {compareCount} - - )} - - )} - - )} - - ) - - // Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist. - // Seit Runde 4 liegen die Agentenseiten nicht mehr unterhalb des - // Basispfads — die Liste muss deshalb auch auf einer Agentenseite - // offen bleiben, sonst verschwände die Navigation beim Hinklicken. - // Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene. - const subItems = item.children ?? [] - const inSection = - subItems.length > 0 && - (pathname.startsWith(item.path) || subItems.some(c => pathname.startsWith(c.path))) - - return collapsed ? ( - - {navContent} - - ) : ( - - {navContent} - {inSection && ( - - {subItems.map((child) => ( - - onClose?.()} - > - {({ isActive }) => ( - - {child.agentId && ( - - )} - - - {child.label} - - {child.role && ( - - {child.role} - - )} - - - )} - - - ))} - - )} - - ) - })} - - - {/* Bottom section */} - - {!collapsed && ( - - - {getUserInitials(userName)} - - - - {userName} - - - {orgName} - - - - )} - - - {collapsed ? : } - - - - ) -} diff --git a/src/components/layout/AppShellTopBar.tsx b/src/components/layout/AppShellTopBar.tsx index fe9f84d..668e4ca 100644 --- a/src/components/layout/AppShellTopBar.tsx +++ b/src/components/layout/AppShellTopBar.tsx @@ -1,39 +1,28 @@ -import { Box, IconButton } from '@mui/material' -import { Menu } from 'lucide-react' +import { Box } from '@mui/material' import { OrganizationContextBadge } from './OrganizationContextBadge' import { NotificationButton } from './NotificationButton' -import { UserMenu } from './UserMenu' +import { GlobalNavMenu } from './GlobalNavMenu' import { AgentChatStickyBar } from '../team' -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - export interface TopBarProps { pathname: string - onMenuClick?: () => void - isMobile?: boolean } -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - /** * Kopfzeile der Anwendung. * - * Sie trägt nur noch, was global gilt: Mandant, Benachrichtigungen und Konto. - * Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides - * stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie - * geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über. + * Sie trägt seit Runde 6 die gesamte Navigation: Mandant, Benachrichtigungen + * und — an der Stelle des früheren Profilsymbols — ein Menüsymbol, das das + * globale Klappmenü öffnet (§11, §15). Das Profil ist vollständig in dieses + * Menü gewandert; in der Leiste selbst steht es nicht mehr. * - * Seit Runde 5 steht in der freien Mitte der kompakte Agentenchat, sobald der - * ausführliche Chatkopf weggescrollt ist (§9). Er liegt im Balken selbst und - * nicht darüber — deshalb kann er Tabellen und Filter nicht verdecken. Der - * Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt, aber unter - * Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt (§10). + * In der freien Mitte erscheint der kompakte Agentenchat, sobald der + * ausführliche Chatkopf weggescrollt ist (Runde 5, §9). Er liegt im Balken + * selbst und nicht darüber — deshalb kann er Tabellen und Filter nicht + * verdecken. Der Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt, + * aber unter Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt. */ -export function TopBar({ onMenuClick, isMobile }: TopBarProps) { +export function TopBar(_props: TopBarProps) { return ( - {/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */} - - {isMobile && ( - - - - )} - + {/* Links bleibt frei — die Navigation liegt vollständig rechts. */} + - {/* Right side */} - + ) diff --git a/src/components/layout/GlobalNavMenu.tsx b/src/components/layout/GlobalNavMenu.tsx new file mode 100644 index 0000000..da1f489 --- /dev/null +++ b/src/components/layout/GlobalNavMenu.tsx @@ -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 = { + 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) + 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 ( + <> + + setAnchorEl(open ? null : e.currentTarget)} + aria-label="Menü öffnen" + aria-haspopup="true" + aria-expanded={open} + sx={{ color: DS_TEXT.primary }} + > + + + + + + + go(ROUTES.SUPPLY.PROPERTIES)} /> + + + + + + go(ROUTES.SUPPLY.TEAM)} /> + {/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */} + {AGENT_WORKSPACES.map(agent => ( + go(agent.path)}> + + {agent.name} + + + {agent.role} + + + + ))} + + + + + + showProfileHint(showToast, close)} /> + {currentUser && ( + + {currentUser.name} · {ROLE_LABELS[currentUser.role] ?? currentUser.role} + {currentUser.organizationName ? ` · ${currentUser.organizationName}` : ''} + + )} + + showProfileHint(showToast, close)}> + } label="Profil" /> + + { close(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}> + } label="Einstellungen" /> + + { + // 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() + }} + > + } label="Produkttour starten" /> + + + } label="Abmelden" /> + + + + + ) +} + +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 {children} +} + +/** Hauptpunkt: linksbündig, kräftig — er trägt die Hierarchie. */ +function MainItem({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + + {label} + + + ) +} + +/** + * 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 ( + + {children} + + ) +} + +function ProfileLabel({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( + <> + {label} + {icon} + + ) +} diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx deleted file mode 100644 index 2dd2621..0000000 --- a/src/components/layout/UserMenu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router' -import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material' -import { HelpCircle, LogOut, Settings, User } from 'lucide-react' -import { useSessionStore } from '../../stores/sessionStore' -import { useToastStore } from '../../stores/toastStore' -import { DS_BRAND } from '../../lib/ds' - -const ROLE_LABELS: Record = { - PROPERTY_MANAGER: 'Property Manager', - DEMAND_USER: 'Demand User', -} - -function getUserInitials(name: string): string { - return name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase() - .slice(0, 2) -} - -export function UserMenu() { - const [anchorEl, setAnchorEl] = useState(null) - const { currentUser, logout } = useSessionStore() - const navigate = useNavigate() - const showToast = useToastStore((s) => s.showToast) - - function handleOpen(e: React.MouseEvent) { - setAnchorEl(e.currentTarget) - } - - function handleClose() { - setAnchorEl(null) - } - - function handleLogout() { - handleClose() - logout() - navigate('/auth/login') - } - - const initials = getUserInitials(currentUser?.name ?? 'U') - - return ( - <> - - - {initials} - - - - - {/* User info header */} - {currentUser && ( - - {currentUser.name} - - {ROLE_LABELS[currentUser.role] ?? currentUser.role} - - - {currentUser.organizationName} - - - )} - - - - { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}> - - Profil - - { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}> - - Einstellungen - - { handleClose(); localStorage.removeItem('property_match_welcomed'); window.location.reload() }}> - - Produkttour starten - - - {/* Der Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» ist entfernt - (Runde 5, §8). Die Flächensuche existiert in diesem Stand nicht mehr; - eine Umschaltung auf eine Rolle ohne erreichbare Seiten wäre eine - Sackgasse im Profilmenü gewesen. */} - - - - - - Abmelden - - - - ) -} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 90cbbd0..89ef854 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -2,6 +2,6 @@ export { AppShell } from './AppShell' export { PageHeader } from './PageHeader' export { RightContextPanel } from './RightContextPanel' export { CompareTray } from './CompareTray' -export { UserMenu } from './UserMenu' +export { GlobalNavMenu } from './GlobalNavMenu' export { NotificationButton } from './NotificationButton' export { OrganizationContextBadge } from './OrganizationContextBadge' diff --git a/src/components/staff/AgentStaffCard.tsx b/src/components/staff/AgentStaffCard.tsx deleted file mode 100644 index 8674696..0000000 --- a/src/components/staff/AgentStaffCard.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { memo } from 'react' -import { Box, ButtonBase, Typography } from '@mui/material' -import { ArrowRight } from 'lucide-react' -import { AgentAvatar } from '../team' -import type { AgentPresence } from '../../domain/agentPresence' -import { WorkloadTone } from '../../domain/agentWorkload' -import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds' - -/** - * Farben der Auslastung auf dunklem Grund. Bewusst hellere Stufen als die - * Standardakzente — die dunkleren Töne verschwinden vor der abgedunkelten - * Bürofläche. - */ -const TONE_COLOR: Record = { - [WorkloadTone.CALM]: DS_ACCENT.success.emerald, - [WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber, - [WorkloadTone.URGENT]: DS_ACCENT.danger.bright, -} - -export interface AgentStaffCardProps { - presence: AgentPresence - onOpen: (path: string) => void -} - -/** - * Ein digitaler Mitarbeitender auf der Startseite (Runde 5, §3/§6). - * - * Die Karte ist bewusst wie ein Personalblatt aufgebaut und nicht wie eine - * Kachel: Porträt, Name, Funktion, Abteilung und Personalnummer, dann ein Satz - * in der ersten Person, darunter die zuletzt protokollierte Handlung mit - * Zeitangabe. - * - * Diese letzte Zeile ist der Unterschied zwischen «KI-Funktion» und - * «Mitarbeitender»: sie belegt, dass jemand gearbeitet hat, statt es zu - * behaupten. Eine Kennzahl allein kann jede Software anzeigen. - * - * Die ganze Karte ist ein Schalter. `ButtonBase` liefert Tastaturbedienung, - * Enter, Leertaste und Fokusring ohne eigenen Code. - */ -export const AgentStaffCard = memo(function AgentStaffCard({ presence, onOpen }: AgentStaffCardProps) { - const { workload } = presence - const toneColor = workload ? TONE_COLOR[workload.tone] : DS_NEUTRAL.stone - - return ( - onOpen(presence.path)} - aria-label={ - workload - ? `${presence.name}, ${presence.role} — ${workload.label}. Arbeitsbereich öffnen` - : `${presence.name}, ${presence.role} — Arbeitsbereich öffnen` - } - sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - textAlign: 'left', - // Ohne `minWidth: 0` kann eine Flexkarte nicht unter ihre Inhaltsbreite - // schrumpfen. Die fünf Karten passten dadurch nicht in eine Reihe, - // brachen um — und die zweite Reihe drückte Titel und Filterbalken aus - // dem sichtbaren Bereich. - minWidth: 0, - gap: 1.25, - p: { xs: 1.75, xl: 2 }, - borderRadius: 3, - overflow: 'hidden', - // Keine eigene Höhe: im Flexlayout gibt die Zeile die Höhe vor, und alle - // Karten einer Zeile werden von selbst gleich hoch. Mit `height: 100%` - // beanspruchte jede Karte die volle Fläche und die zweite Zeile fiel - // aus dem sichtbaren Bereich. - // Glasfläche statt Vollton: das Büro dahinter bleibt spürbar, ohne die - // Schrift zu stören. - bgcolor: 'rgba(255,255,255,0.06)', - backdropFilter: 'blur(14px)', - border: '1px solid rgba(255,255,255,0.12)', - transition: 'background-color 0.18s ease, transform 0.18s ease, border-color 0.18s ease', - '&:hover, &.Mui-focusVisible': { - bgcolor: 'rgba(255,255,255,0.11)', - borderColor: 'rgba(255,255,255,0.26)', - transform: 'translateY(-3px)', - }, - '&:hover .staff-go, &.Mui-focusVisible .staff-go': { opacity: 1, transform: 'translateX(0)' }, - '&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 }, - }} - > - {/* Kopfzeile: Porträt, Personalien, Dienstzustand */} - - - - {/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */} - - - - - - {presence.name} - - - {presence.role} - - {(presence.department || presence.personnelNumber) && ( - - {[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')} - - )} - - - - {/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */} - {presence.autonomy && ( - - - {presence.autonomy} - - - )} - - {/* Was er dazu sagt — in der ersten Person. */} - {workload && ( - - - - {workload.voice} - - - )} - - {/* Beleg: was zuletzt tatsächlich getan wurde. */} - - {presence.lastAction ? ( - <> - - Zuletzt · {presence.lastAction.when} - - - - {presence.lastAction.title} - - - - - - - ) : ( - - Noch keine Tätigkeit protokolliert. - - )} - - - ) -}) diff --git a/src/components/staff/AgentStaffOverview.tsx b/src/components/staff/AgentStaffOverview.tsx deleted file mode 100644 index d0887fb..0000000 --- a/src/components/staff/AgentStaffOverview.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router' -import { Box, Typography } from '@mui/material' -import { StaffBackdrop } from './StaffBackdrop' -import { AgentStaffCard } from './AgentStaffCard' -import { useAgentPresence } from '../../hooks/useAgentPresence' -import { WorkloadTone } from '../../domain/agentWorkload' -import { DS_NEUTRAL } from '../../lib/ds' - -/** - * Mindesthöhe, damit die Belegschaft eine Fläche hat und nicht als schmaler - * Streifen über der Objektliste klebt. - * - * Eine feste oder gedeckelte Höhe hatte dieser Bereich früher — und genau das - * war der Fehler: passte der Inhalt nicht hinein, scrollte er in sich selbst, - * die Porträts wurden oben abgeschnitten und neben der Seite stand ein zweiter - * Scrollbalken. Die Höhe richtet sich deshalb nach dem Inhalt. Damit der Titel - * «Meine Objekte» beim Einstieg sichtbar bleibt (§2.3), stehen die fünf - * Mitarbeitenden auf grossen Schirmen in einer einzigen Reihe statt in zweien. - */ -const MIN_SCENE_PX = 360 - -/** - * Grösste Breite der Karten samt Anriss. Freilaufend würden bei drei Spalten - * auf einem 2560er Monitor Karten von über 700 Pixeln entstehen — flache - * Streifen, in denen zwei Textzeilen verloren stehen. Der Hintergrund bleibt - * randlos, nur der Inhalt wird gefasst und mittig gestellt. - */ -const MAX_CONTENT_PX = 1280 - -/** - * Die digitale Belegschaft auf der Startseite (Runde 5, §3). - * - * Die fünf Mitarbeitenden an ihrem Arbeitsplatz — mit Porträt, Funktion, - * Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person - * und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet - * den jeweiligen Arbeitsbereich. Die Personalverwaltung selbst liegt im - * Menüpunkt «Meine Agenten» und braucht keine eigene Person am Bildschirm. - * - * Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse - * bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum - * allein macht niemanden menschlich. Was menschlich wirkt, ist dasselbe wie im - * echten Büro — dass jemand da ist, etwas zu sagen hat und nachweisbar - * gearbeitet hat. - */ -export function AgentStaffOverview() { - const navigate = useNavigate() - const { data: staff } = useAgentPresence() - - const handleOpen = useCallback((path: string) => navigate(path), [navigate]) - - // Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4). - const { urgent, onDuty } = useMemo(() => { - let urgentCount = 0 - let onDutyCount = 0 - for (const person of staff) { - if (person.workload?.tone === WorkloadTone.URGENT) urgentCount += 1 - if (person.onDuty) onDutyCount += 1 - } - return { urgent: urgentCount, onDuty: onDutyCount } - }, [staff]) - - return ( - - - - - - - *': { - flex: { - xs: '1 1 100%', - sm: '0 1 calc((100% - 12px) / 2)', - md: '0 1 calc((100% - 24px) / 3)', - lg: '0 1 calc((100% - 64px) / 5)', - }, - }, - }} - > - {staff.map(person => ( - - ))} - - - - ) -} - -/** - * Anriss über der Belegschaft. - * - * Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute - * irgendwo eingreifen? Ohne den Satz müsste man jede Karte einzeln lesen und - * selbst zusammenzählen. - */ -function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) { - const summary = - urgent > 0 - ? `${urgent} ${urgent === 1 ? 'Mitarbeitender braucht' : 'Mitarbeitende brauchen'} heute eine Entscheidung von dir` - : 'Alle laufen — heute liegt nichts bei dir' - - return ( - - - - - {onDuty} von {total} im Dienst - - - - {summary} - - - ) -} diff --git a/src/components/staff/MeetingRoom3D.tsx b/src/components/staff/MeetingRoom3D.tsx new file mode 100644 index 0000000..5d2cc16 --- /dev/null +++ b/src/components/staff/MeetingRoom3D.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router' +import * as THREE from 'three' +import { Box, Typography } from '@mui/material' +import { createMeetingRoomScene } from './meetingRoomScene' +import type { MeetingRoomScene } from './meetingRoomScene' +import { MEETING_SEATS } from './meetingRoomSeats' +import { AGENT_PHOTOS } from '../team/agentPhotos' +import { useAgentWorkload } from '../../hooks/useAgentWorkload' +import { WorkloadTone } from '../../domain/agentWorkload' +import { DS_ACCENT } from '../../lib/ds' +import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds' + +/** + * Farben der Auslastung auf dunklem Grund. Hellere Stufen als die + * Standardakzente — die dunkleren verschwinden vor der Szene. + */ +const TONE_COLOR: Record = { + [WorkloadTone.CALM]: DS_ACCENT.success.emerald, + [WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber, + [WorkloadTone.URGENT]: DS_ACCENT.danger.bright, +} + +/** + * Höhe der Kopfzeile plus des Streifens, der unter dem Raum sichtbar bleiben + * muss: Titel «Meine Objekte» und der Beginn von «Filter & Übersicht» + * (Runde 6, §1.1). + */ +const TOP_BAR_PX = 64 +const KEEP_VISIBLE_BELOW_PX = 124 + +/** Unter dieser Höhe verliert die Perspektive ihre Wirkung. */ +const MIN_HEIGHT_PX = 380 +/** Darüber wird der Raum zur Landschaft und die Objektliste rutscht zu weit weg. */ +const MAX_HEIGHT_PX = 760 + +/** + * Virtueller 3D-Meetingraum auf der Startseite (Runde 6, §1–§9). + * + * Der Betrachter sitzt am Kopfende des Tisches und sieht seine Agenten vor sich. + * Die Kamera ist fest; als einzige Bewegung folgt sie der Maus um wenige + * Zentimeter, damit die Szene lebt, ohne die verbindliche Perspektive zu + * verlassen (§20). + * + * Die Beschriftungen sind HTML und nicht Teil der 3D-Szene. Als Textur im Raum + * wären sie bei Tiefe unlesbar; als DOM-Element bleiben sie scharf, lassen sich + * mit den Mitteln des Designsystems gestalten und sind für Screenreader + * vorhanden. Ihre Position kommt aus der Szene: jede Bildwiederholung projiziert + * den Ankerpunkt über dem Kopf auf den Bildschirm. + * + * Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für + * eine statische Szene verschwendete Rechenzeit (§21). + */ +export function MeetingRoom3D() { + const navigate = useNavigate() + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const sceneRef = useRef(null) + const labelRefs = useRef(new Map()) + const hoveredRef = useRef(null) + const [hovered, setHovered] = useState(null) + // Beim Hover zeigt die Plakette zusätzlich, woran der Agent gerade sitzt. + // Ruhend bleibt sie bei Name und Funktion (§8). + const { data: workload } = useAgentWorkload() + + /** + * Beschriftungen an die projizierten Kopfpositionen setzen. + * + * Danach folgt ein Entzerrungsdurchgang: Plätze weiter hinten rücken in der + * Perspektive auf wenige Bildpunkte zusammen, ihre Plaketten lägen dort + * übereinander. Wer mit einem bereits gesetzten Nachbarn kollidiert, wandert + * so weit nach oben, bis er frei steht. + * + * Bewusst nach der Projektion und nicht über handverlesene Höhen in der + * Szene: gerechnete Abstände halten auf jeder Fenstergrösse, fest gesetzte + * Werte gelten nur für die Breite, an der sie eingestellt wurden. + */ + const positionLabels = useCallback(() => { + const s = sceneRef.current + const wrap = wrapRef.current + if (!s || !wrap) return + const w = wrap.clientWidth + const h = wrap.clientHeight + const v = new THREE.Vector3() + + type Placed = { el: HTMLDivElement; x: number; y: number; halfW: number; height: number } + const placed: Placed[] = [] + + // Von vorne nach hinten: die vorderen Plaketten behalten ihren Platz, die + // hinteren geben nach. Das entspricht der Tiefenordnung im Bild. + const ordered = [...s.seats].sort((a, b) => b.labelAnchor.z - a.labelAnchor.z) + + for (const handle of ordered) { + const el = labelRefs.current.get(handle.seat.id) + if (!el) continue + + v.copy(handle.labelAnchor).project(s.camera) + let y = (-v.y * 0.5 + 0.5) * h + + // Weiter hinten sitzende Beschriftungen werden kleiner, bleiben aber über + // einer Untergrenze lesbar (§8 «bei räumlicher Tiefe ausreichend lesbar»). + const scale = Math.max(0.68, Math.min(1, 1 - (v.z - 0.955) * 8)) + const halfW = (el.offsetWidth * scale) / 2 + const height = el.offsetHeight * scale + + // Innerhalb des Bildes halten: auf schmalen Fenstern liegen die äusseren + // Plätze so weit aussen, dass ihre Beschriftung sonst abgeschnitten wird. + const x = Math.min(Math.max((v.x * 0.5 + 0.5) * w, halfW + 6), w - halfW - 6) + + for (const other of placed) { + const overlapsX = Math.abs(x - other.x) < halfW + other.halfW + 6 + const overlapsY = Math.abs(y - other.y) < Math.max(height, other.height) + 4 + if (overlapsX && overlapsY) { + y = other.y - Math.max(height, other.height) - 6 + } + } + + placed.push({ el, x, y, halfW, height }) + el.style.transform = + `translate(-50%, -100%) translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${scale.toFixed(3)})` + } + }, []) + + const draw = useCallback(() => { + sceneRef.current?.render() + positionLabels() + }, [positionLabels]) + + // ── Szene aufbauen ── + useEffect(() => { + const canvas = canvasRef.current + const wrap = wrapRef.current + if (!canvas || !wrap) return + + const s = createMeetingRoomScene(canvas, MEETING_SEATS, AGENT_PHOTOS) + sceneRef.current = s + s.setSize(wrap.clientWidth, wrap.clientHeight) + s.render() + positionLabels() + + // Texturen kommen asynchron; ohne ein zweites Zeichnen bliebe die Szene + // beim Stand des ersten Bildes stehen — ohne Fotos, ohne Kulisse. + THREE.DefaultLoadingManager.onLoad = () => { + s.render() + positionLabels() + } + + const ro = new ResizeObserver(() => { + s.setSize(wrap.clientWidth, wrap.clientHeight) + s.render() + positionLabels() + }) + ro.observe(wrap) + + return () => { + THREE.DefaultLoadingManager.onLoad = () => {} + ro.disconnect() + s.dispose() + sceneRef.current = null + } + }, [positionLabels]) + + // ── Maus: Parallaxe und Trefferprüfung ── + const handlePointerMove = useCallback((e: React.PointerEvent) => { + const s = sceneRef.current + const wrap = wrapRef.current + if (!s || !wrap) return + + const rect = wrap.getBoundingClientRect() + const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1 + const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1) + + s.applyParallax(nx, ny) + + const ray = new THREE.Raycaster() + ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera) + const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0] + const id = hit ? (s.seats.find(h => h.hitBox === hit.object)?.seat.id ?? null) : null + + if (id !== hoveredRef.current) { + hoveredRef.current = id + s.setHovered(id) + setHovered(id) + } + draw() + }, [draw]) + + const handlePointerLeave = useCallback(() => { + const s = sceneRef.current + if (!s) return + s.applyParallax(0, 0) + hoveredRef.current = null + s.setHovered(null) + setHovered(null) + draw() + }, [draw]) + + const handleClick = useCallback(() => { + const id = hoveredRef.current + if (!id) return + const seat = MEETING_SEATS.find(x => x.id === id) + if (seat) navigate(seat.path) + }, [navigate]) + + return ( + + + + {/* + Beschriftungen über den Köpfen (§8). Sie liegen über dem Bild, nehmen + aber keine Klicks an — der Klick gehört der Szene darunter, damit der + Trefferkörper des Agenten entscheidet und nicht die Textbreite. + */} + {MEETING_SEATS.map(seat => { + const on = hovered === seat.id + return ( + { labelRefs.current.set(seat.id, el) }} + sx={{ + position: 'absolute', + top: 0, + left: 0, + pointerEvents: 'none', + textAlign: 'center', + whiteSpace: 'nowrap', + px: 1, + py: 0.375, + borderRadius: 1.5, + transformOrigin: 'bottom center', + transition: 'background-color 0.15s ease, box-shadow 0.15s ease', + // Kein starker Kasten, der den Raum verdeckt (§8) — aber ohne jede + // Unterlage verschwindet weisse Schrift vor der hellen Glasfront. + // Deshalb ein leichter Schleier mit Weichzeichnung, der bei Hover + // etwas dichter wird. + bgcolor: on ? 'rgba(11,18,29,0.74)' : 'rgba(11,18,29,0.34)', + backdropFilter: 'blur(3px)', + boxShadow: on ? '0 6px 20px rgba(0,0,0,0.35)' : 'none', + }} + > + + {seat.name} + + + {seat.role} + + + {/* + Nur bei Hover: die aktuelle Auslastung. Ruhend bliebe die Plakette + sonst überdimensioniert (§8), im Hover ist die Hervorhebung + ausdrücklich erlaubt — und sie sagt vor dem Klick, was drüben liegt. + */} + {on && workload[seat.id] && ( + + {workload[seat.id].label} + + )} + + ) + })} + + {/* + Tastaturweg zu denselben Zielen. Die Szene selbst ist ein Canvas und + damit für die Tastatur unerreichbar; ohne diese Schalter wären die + Agenten nur mit der Maus erreichbar. + */} + + {MEETING_SEATS.map(seat => ( + + ))} + + + + Kleinbasel · Blick über den Rhein nach Grossbasel + + + ) +} diff --git a/src/components/staff/StaffBackdrop.tsx b/src/components/staff/StaffBackdrop.tsx deleted file mode 100644 index 14e32e1..0000000 --- a/src/components/staff/StaffBackdrop.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { memo } from 'react' -import { Box } from '@mui/material' -import { ROOM_BACKDROP } from './roomBackdrop' -import { DS_NEUTRAL } from '../../lib/ds' - -/** - * Bildhintergrund der Belegschaftsansicht (Runde 5, §3). - * - * Bewusst eine einzige Bildfläche mit Verlauf — und nicht länger ein aus - * Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben - * fotografierten Porträts immer erkennbar gezeichnet; die Bildwirkung muss - * deshalb aus dem Foto kommen, nicht aus Geometrie. - * - * Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was - * `ROOM_BACKDROP` liefert — die Aussicht heute, eine Raumaufnahme später. - */ -export const StaffBackdrop = memo(function StaffBackdrop() { - return ( - -