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}
-
-
-
-
- >
- )
-}
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 (
-
-
-
-
-
-
- )
-})
diff --git a/src/components/staff/index.ts b/src/components/staff/index.ts
index 5427749..f97061a 100644
--- a/src/components/staff/index.ts
+++ b/src/components/staff/index.ts
@@ -1,5 +1,5 @@
-// Digitale Belegschaft auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13).
+// Virtueller 3D-Meetingraum der Startseite (Runde 6). Barrel-Export (CLAUDE.md §13).
-export { AgentStaffOverview } from './AgentStaffOverview'
-export { AgentStaffCard } from './AgentStaffCard'
-export { ROOM_BACKDROP } from './roomBackdrop'
+export { MeetingRoom3D } from './MeetingRoom3D'
+export { MEETING_SEATS, SeatSide } from './meetingRoomSeats'
+export type { MeetingSeat } from './meetingRoomSeats'
diff --git a/src/components/staff/meetingRoomPalette.ts b/src/components/staff/meetingRoomPalette.ts
new file mode 100644
index 0000000..9fcf1ec
--- /dev/null
+++ b/src/components/staff/meetingRoomPalette.ts
@@ -0,0 +1,96 @@
+/**
+ * Farben und Maße des 3D-Meetingraums (Runde 6, §3/§5).
+ *
+ * Die Farben stehen als Zahlen und nicht als Hexzeichenketten, weil three.js
+ * Zahlen erwartet — `new Color(0x…)` ist der direkte Weg, jede Umwandlung
+ * dazwischen wäre nur Umweg. Sie liegen hier statt in der Szene, damit sich die
+ * Materialstimmung an einer Stelle ändern lässt (CLAUDE.md §8).
+ *
+ * Der Raum ist «modern, hochwertig, zurückhaltend, nicht futuristisch»: warmes
+ * Eichenholz, heller Putz, Anthrazitteppich, mattgraue Fensterprofile. Als
+ * einziger Markenbezug trägt die geschlossene Wand einen schmalen Streifen im
+ * Property-Match-Blau (§3 «darf subtil aufgenommen werden»).
+ */
+
+export const ROOM_COLORS = {
+ floor: 0x4b4741,
+ floorSheen: 0x5d5852,
+ ceiling: 0xf6f5f2,
+ ceilingRail: 0xeeebe5,
+ /** Geschlossene Wand rechts: heller Putz mit Eichenpaneel im unteren Drittel. */
+ wall: 0xe8e4dc,
+ wallPanel: 0x9c7b52,
+ wallSkirting: 0xd6d1c7,
+ /** Schmaler Markenstreifen auf der geschlossenen Wand. */
+ brandStripe: 0x152642,
+ /** Fensterprofile und Pfosten. */
+ mullion: 0x6b7079,
+ frame: 0x585d65,
+ /** Glasscheibe: sehr schwacher Blauschleier, sonst wäre sie unsichtbar. */
+ glass: 0xd8e8f0,
+ /** Tischplatte in Eiche, Kante etwas dunkler. */
+ tableTop: 0x8f7350,
+ tableEdge: 0x6f5637,
+ tableBase: 0x35322f,
+ /** Stuhlschalen. */
+ chair: 0x2f343c,
+ /** Anzüge der Agenten, leicht variiert, damit die Reihe nicht uniform wirkt. */
+ suits: [0x2c3444, 0x36302c, 0x2a3a3c, 0x3a2f36, 0x24303a, 0x1f2836],
+ /** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
+ headRing: 0xffffff,
+ /** Ring bei Hover: Gold aus dem Designsystem. */
+ headRingHover: 0xb8975a,
+ /** Himmel hinter der Kulisse, falls doch ein Rand sichtbar wird. */
+ sky: 0xbcd7e6,
+} as const
+
+/**
+ * Raumgeometrie in Metern. Der Betrachter sitzt am Kopfende bei z ≈ 0 und
+ * schaut in Richtung −z; die Fensterfront gegenüber liegt bei z = −ROOM.depth.
+ */
+export const ROOM = {
+ width: 7.4,
+ height: 3.05,
+ depth: 10.6,
+} as const
+
+/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
+export const CAMERA = {
+ /** Sitzende Augenhöhe über dem Boden. */
+ eyeHeight: 1.38,
+ /** Knapp hinter der vorderen Tischkante. */
+ z: 1.9,
+ fov: 48,
+ /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
+ lookAt: [0, 1.06, -6.2] as const,
+ /**
+ * Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
+ * Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
+ */
+ parallax: 0.075,
+} as const
+
+/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
+export const TABLE = {
+ width: 1.62,
+ /** Höhe der Plattenoberseite über dem Boden. */
+ top: 0.745,
+ thickness: 0.06,
+ /** Vordere Kante, knapp vor der Kamera. */
+ zNear: 0.15,
+ /** Hintere Kante, vor dem Platz von Thomas. */
+ zFar: -7.35,
+} as const
+
+/** Sitzende Personen: Kopfhöhe und Grössen. */
+export const SEATED = {
+ /** Kopfmitte über dem Boden — deutlich über der Tischplatte. */
+ headY: 1.30,
+ headRadius: 0.155,
+ /** Torso vom Stuhl bis zum Hals. */
+ torsoTop: 1.12,
+ torsoBottom: 0.42,
+ torsoRadiusTop: 0.19,
+ torsoRadiusBottom: 0.25,
+ shoulderWidth: 0.46,
+} as const
diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts
new file mode 100644
index 0000000..e1c0577
--- /dev/null
+++ b/src/components/staff/meetingRoomScene.ts
@@ -0,0 +1,509 @@
+import * as THREE from 'three'
+import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
+import { SeatSide } from './meetingRoomSeats'
+import type { MeetingSeat } from './meetingRoomSeats'
+import baselFront from '../../assets/staff/basel-front.jpg'
+import baselLeft from '../../assets/staff/basel-left.jpg'
+
+/**
+ * Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
+ *
+ * Bewusst ohne React: hier entsteht nur der three.js-Graph. Die Komponente
+ * daneben kümmert sich um Einbettung, Grösse, Maus und Beschriftungen. Diese
+ * Trennung hält beide Teile lesbar — eine Datei mit Szene *und* Reactzustand
+ * wäre unübersichtlich und schwer zu verändern.
+ *
+ * Die Kamera sitzt auf Augenhöhe einer sitzenden Person am Kopfende des
+ * Tisches und ist fest: keine Orbit-Steuerung, keine freie Navigation (§20).
+ * Der Tisch läuft von ihr weg in den Raum, links und rechts sitzen die
+ * Agenten, am gegenüberliegenden Kopfende Thomas.
+ *
+ * Es wird **nicht** dauernd gerendert. Die Komponente ruft `render()` nur bei
+ * Grössenänderung, Mausbewegung und Zustandswechsel auf — ein Dauerloop wäre
+ * für eine statische Szene verschwendete Rechenzeit (§21).
+ */
+
+/** Ein anklickbarer Platz samt seiner Teile in der Szene. */
+export interface SeatHandle {
+ seat: MeetingSeat
+ /** Unsichtbarer Trefferkörper für den Raycaster. */
+ hitBox: THREE.Mesh
+ /** Ring hinter dem Porträt — wird bei Hover golden. */
+ ring: THREE.Mesh
+ /** Gruppe aus Kopf, Ring und Körper; wird bei Hover leicht angehoben. */
+ group: THREE.Group
+ /** Weltposition für die schwebende Beschriftung (über dem Kopf). */
+ labelAnchor: THREE.Vector3
+}
+
+export interface MeetingRoomScene {
+ scene: THREE.Scene
+ camera: THREE.PerspectiveCamera
+ renderer: THREE.WebGLRenderer
+ seats: SeatHandle[]
+ /** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
+ applyParallax: (dx: number, dy: number) => void
+ setSize: (width: number, height: number) => void
+ render: () => void
+ setHovered: (id: string | null) => void
+ dispose: () => void
+}
+
+// ── Bausteine ────────────────────────────────────────────────────────────────
+
+function addRoomShell(scene: THREE.Scene) {
+ const { width, height, depth } = ROOM
+ const halfW = width / 2
+
+ // Boden — ein leichter Verlauf über zwei Flächen wäre teuer; ein matter Ton
+ // mit heller Kante zur Fensterseite genügt für die Tiefenwirkung.
+ const floor = new THREE.Mesh(
+ new THREE.PlaneGeometry(width, depth),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.floor }),
+ )
+ floor.rotation.x = -Math.PI / 2
+ floor.position.set(0, 0, -depth / 2)
+ scene.add(floor)
+
+ // Lichtstreifen auf dem Boden entlang der Glasfront
+ const sheen = new THREE.Mesh(
+ new THREE.PlaneGeometry(1.6, depth),
+ new THREE.MeshBasicMaterial({ color: ROOM_COLORS.floorSheen, transparent: true, opacity: 0.5 }),
+ )
+ sheen.rotation.x = -Math.PI / 2
+ sheen.position.set(-halfW + 1.1, 0.004, -depth / 2)
+ scene.add(sheen)
+
+ const ceiling = new THREE.Mesh(
+ new THREE.PlaneGeometry(width, depth),
+ // Unbeleuchtet: eine Lambertfläche, die nach unten schaut, bekommt von
+ // Licht aus dem Raum nichts ab und säuft zu Grau ab.
+ new THREE.MeshBasicMaterial({ color: ROOM_COLORS.ceiling }),
+ )
+ ceiling.rotation.x = Math.PI / 2
+ ceiling.position.set(0, height, -depth / 2)
+ scene.add(ceiling)
+
+ // Deckenschiene über dem Tisch — gibt der Decke Struktur ohne Dekoration
+ const rail = new THREE.Mesh(
+ new THREE.BoxGeometry(0.14, 0.035, depth * 0.66),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.ceilingRail }),
+ )
+ rail.position.set(0, height - 0.05, -depth / 2)
+ scene.add(rail)
+
+ // ── Rechte Wand: geschlossen (§3) ──
+ const wall = new THREE.Mesh(
+ new THREE.PlaneGeometry(depth, height),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wall }),
+ )
+ wall.rotation.y = -Math.PI / 2
+ wall.position.set(halfW, height / 2, -depth / 2)
+ scene.add(wall)
+
+ // Eichenpaneel im unteren Drittel
+ const panel = new THREE.Mesh(
+ new THREE.PlaneGeometry(depth, 0.95),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallPanel }),
+ )
+ panel.rotation.y = -Math.PI / 2
+ panel.position.set(halfW - 0.01, 0.475, -depth / 2)
+ scene.add(panel)
+
+ // Schmaler Markenstreifen — der einzige Markenbezug im Raum
+ const stripe = new THREE.Mesh(
+ new THREE.PlaneGeometry(depth, 0.03),
+ new THREE.MeshBasicMaterial({ color: ROOM_COLORS.brandStripe }),
+ )
+ stripe.rotation.y = -Math.PI / 2
+ stripe.position.set(halfW - 0.02, 0.99, -depth / 2)
+ scene.add(stripe)
+
+ const skirting = new THREE.Mesh(
+ new THREE.PlaneGeometry(depth, 0.08),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
+ )
+ skirting.rotation.y = -Math.PI / 2
+ skirting.position.set(halfW - 0.01, 0.04, -depth / 2)
+ scene.add(skirting)
+}
+
+/**
+ * Basler Fernkulisse (§4).
+ *
+ * Zwei grosse Flächen weit aussen vor den Glasfronten, texturiert mit den beiden
+ * Referenzfotos aus dem Auftrag. Sie stehen so weit weg, dass die Perspektive
+ * sie als Ferne liest und die Fensterpfosten davor sauber schneiden.
+ *
+ * Beide Fotos zeigen dieselbe Rheinseite von derselben Uferseite. Damit der
+ * Blick nach vorne und der Blick nach links zusammengehören, liegt die
+ * Wasserlinie beider Kulissen auf derselben Welthöhe — sonst wirkte es wie zwei
+ * zufällig zusammengesetzte Bilder.
+ */
+function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader) {
+ /** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
+ const waterY = -7.5
+
+ function backdrop(url: string, waterFraction: number, w: number, h: number) {
+ const tex = loader.load(url)
+ tex.colorSpace = THREE.SRGBColorSpace
+ const mesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(w, h),
+ // Unbeleuchtet: das Foto trägt seine eigene Belichtung, jede Beleuchtung
+ // würde es nur verfälschen.
+ new THREE.MeshBasicMaterial({ map: tex }),
+ )
+ // Mittelpunkt so setzen, dass die Wasserlinie des Fotos auf `waterY` liegt.
+ mesh.position.y = waterY + h * (waterFraction - 0.5)
+ return mesh
+ }
+
+ // Vorne: Blick über den Rhein auf Altstadt und Münster. Wasserlinie bei ~60 %.
+ const front = backdrop(baselFront, 0.6, 70, 47)
+ front.position.z = -58
+ scene.add(front)
+
+ // Links: Rheinverlauf mit Altstadtsilhouette. Panoramaformat, Wasserlinie ~64 %.
+ const left = backdrop(baselLeft, 0.64, 118, 38)
+ left.rotation.y = Math.PI / 2
+ left.position.x = -56
+ left.position.z = -26
+ scene.add(left)
+}
+
+/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
+function addGlassFacades(scene: THREE.Scene) {
+ const { width, height, depth } = ROOM
+ const halfW = width / 2
+
+ const glassMat = new THREE.MeshBasicMaterial({
+ color: ROOM_COLORS.glass,
+ transparent: true,
+ opacity: 0.07,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ })
+ const frameMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.mullion })
+
+ // Gegenüberliegende Front
+ const frontGlass = new THREE.Mesh(new THREE.PlaneGeometry(width, height), glassMat)
+ frontGlass.position.set(0, height / 2, -depth)
+ scene.add(frontGlass)
+
+ // Linke Front
+ const leftGlass = new THREE.Mesh(new THREE.PlaneGeometry(depth, height), glassMat)
+ leftGlass.rotation.y = Math.PI / 2
+ leftGlass.position.set(-halfW, height / 2, -depth / 2)
+ scene.add(leftGlass)
+
+ /** Senkrechter Pfosten. */
+ function post(x: number, y: number, z: number, rotY = 0) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(0.07, height, 0.09), frameMat)
+ m.position.set(x, y, z)
+ m.rotation.y = rotY
+ scene.add(m)
+ }
+ /** Waagrechter Riegel. */
+ function rail(len: number, x: number, y: number, z: number, rotY = 0) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.07, 0.09), frameMat)
+ m.position.set(x, y, z)
+ m.rotation.y = rotY
+ scene.add(m)
+ }
+
+ // Pfosten der Frontscheibe — Thomas sitzt zwischen zwei Feldern, damit die
+ // Stadtsilhouette rund um ihn sichtbar bleibt (§3 «rund um ihn herum»).
+ for (const x of [-halfW, -1.5, 1.5, halfW]) post(x, height / 2, -depth)
+ rail(width, 0, height - 0.035, -depth)
+ rail(width, 0, 0.035, -depth)
+ rail(width, 0, 2.28, -depth)
+
+ // Pfosten der linken Front, alle 2 m
+ for (let z = 0; z >= -depth; z -= 2) post(-halfW, height / 2, z, Math.PI / 2)
+ rail(depth, -halfW, height - 0.035, -depth / 2, Math.PI / 2)
+ rail(depth, -halfW, 0.035, -depth / 2, Math.PI / 2)
+ rail(depth, -halfW, 2.28, -depth / 2, Math.PI / 2)
+}
+
+/** Meetingtisch samt Sockel und Kontaktschatten (§5). */
+function addTable(scene: THREE.Scene) {
+ const length = TABLE.zNear - TABLE.zFar
+ const zMid = (TABLE.zNear + TABLE.zFar) / 2
+
+ const top = new THREE.Mesh(
+ new THREE.BoxGeometry(TABLE.width, TABLE.thickness, length),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableTop }),
+ )
+ top.position.set(0, TABLE.top, zMid)
+ scene.add(top)
+
+ // Kante minimal dunkler — lässt die Platte dicker und wertiger wirken
+ const edge = new THREE.Mesh(
+ new THREE.BoxGeometry(TABLE.width + 0.02, 0.018, length + 0.02),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableEdge }),
+ )
+ edge.position.set(0, TABLE.top - TABLE.thickness / 2, zMid)
+ scene.add(edge)
+
+ // Zwei Sockelwangen statt vier Beine: verdeckt weniger und wirkt hochwertiger
+ const baseMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableBase })
+ for (const z of [zMid - length * 0.28, zMid + length * 0.28]) {
+ const base = new THREE.Mesh(new THREE.BoxGeometry(TABLE.width * 0.55, TABLE.top - 0.05, 0.12), baseMat)
+ base.position.set(0, (TABLE.top - 0.05) / 2, z)
+ scene.add(base)
+ }
+
+ // Kontaktschatten auf dem Boden — ohne ihn schwebt der Tisch
+ const shadow = new THREE.Mesh(
+ new THREE.PlaneGeometry(TABLE.width + 1.0, length + 0.8),
+ new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28 }),
+ )
+ shadow.rotation.x = -Math.PI / 2
+ shadow.position.set(0, 0.006, zMid)
+ scene.add(shadow)
+}
+
+/**
+ * Ein sitzender Agent (§6).
+ *
+ * Kopf ist eine Kreisfläche mit dem bestehenden Porträt als Textur — die
+ * Kreisgeometrie beschneidet das quadratische Foto rund, genau wie die Avatare
+ * im übrigen Programm. Darunter ein Torso in Anzugfarbe und eine Stuhllehne.
+ *
+ * Der Kopf ist um `yaw` zum Tisch gedreht: die Agenten schauen ins Geschehen
+ * (§2), bleiben dabei aber lesbar. Voll zur Tischmitte gedreht sähe man von
+ * einem frontalen Porträtfoto nur die Kante.
+ */
+function addSeatedAgent(
+ scene: THREE.Scene,
+ loader: THREE.TextureLoader,
+ seat: MeetingSeat,
+ photoUrl: string | undefined,
+ suitColor: number,
+): SeatHandle {
+ const group = new THREE.Group()
+ group.position.set(seat.x, 0, seat.z)
+
+ // Blickrichtung: zum Kopfende des Betrachters hin, aber zur Tischmitte gedreht.
+ const yaw =
+ seat.side === SeatSide.HEAD ? 0
+ : seat.side === SeatSide.LEFT ? 0.62
+ : -0.62
+
+ // ── Torso ──
+ const torsoHeight = SEATED.torsoTop - SEATED.torsoBottom
+ const torso = new THREE.Mesh(
+ new THREE.CylinderGeometry(SEATED.torsoRadiusTop, SEATED.torsoRadiusBottom, torsoHeight, 20, 1),
+ new THREE.MeshLambertMaterial({ color: suitColor }),
+ )
+ torso.position.y = SEATED.torsoBottom + torsoHeight / 2
+ group.add(torso)
+
+ // Schultern: eine flach gedrückte Kugel schliesst den Torso oben ab
+ const shoulders = new THREE.Mesh(
+ new THREE.SphereGeometry(SEATED.shoulderWidth / 2, 20, 12),
+ new THREE.MeshLambertMaterial({ color: suitColor }),
+ )
+ shoulders.scale.set(1, 0.42, 0.66)
+ shoulders.position.y = SEATED.torsoTop - 0.02
+ group.add(shoulders)
+
+ // ── Kopf ──
+ const head = new THREE.Group()
+ head.position.y = SEATED.headY
+ head.rotation.y = yaw
+
+ const ring = new THREE.Mesh(
+ new THREE.CircleGeometry(SEATED.headRadius + 0.018, 40),
+ new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
+ )
+ ring.position.z = -0.004
+ head.add(ring)
+
+ if (photoUrl) {
+ const tex = loader.load(photoUrl)
+ tex.colorSpace = THREE.SRGBColorSpace
+ const face = new THREE.Mesh(
+ new THREE.CircleGeometry(SEATED.headRadius, 40),
+ new THREE.MeshBasicMaterial({ map: tex }),
+ )
+ head.add(face)
+ }
+
+ // Hinterkopf, damit von der Seite kein Loch entsteht
+ const backOfHead = new THREE.Mesh(
+ new THREE.SphereGeometry(SEATED.headRadius * 0.94, 20, 14),
+ new THREE.MeshLambertMaterial({ color: 0x2a2724 }),
+ )
+ backOfHead.position.z = -SEATED.headRadius * 1.05
+ backOfHead.scale.set(1, 1, 0.62)
+ head.add(backOfHead)
+
+ // Hals
+ const neck = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.055, 0.07, 0.16, 12),
+ new THREE.MeshLambertMaterial({ color: 0xc79a7c }),
+ )
+ neck.position.y = SEATED.headY - 0.17
+ group.add(neck)
+
+ group.add(head)
+
+ // Unterarme auf der Platte. Sie kosten zwei Quader und sind der Unterschied
+ // zwischen einem Rumpf hinter einer Kante und einer Person, die am Tisch sitzt.
+ const armMat = new THREE.MeshLambertMaterial({ color: suitColor })
+ for (const sign of [-1, 1]) {
+ const arm = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.075, 0.42), armMat)
+ arm.position.set(0, TABLE.top + 0.045, 0)
+ arm.rotation.y = yaw
+ arm.translateX(sign * 0.16)
+ // Zur Tischmitte hin nach vorne gelegt
+ arm.translateZ(seat.side === SeatSide.HEAD ? 0.3 : 0.3)
+ group.add(arm)
+ }
+
+ // ── Stuhllehne hinter dem Agenten ──
+ const chair = new THREE.Mesh(
+ new THREE.BoxGeometry(0.56, 0.62, 0.06),
+ new THREE.MeshLambertMaterial({ color: ROOM_COLORS.chair }),
+ )
+ chair.position.set(0, 0.86, 0)
+ chair.rotation.y = yaw
+ chair.translateZ(-0.28)
+ group.add(chair)
+
+ // ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
+ const hitBox = new THREE.Mesh(
+ new THREE.BoxGeometry(0.94, 1.34, 0.82),
+ // Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
+ // das Objekt aus der Strahlprüfung nehmen.
+ new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
+ )
+ hitBox.position.y = 0.9
+ group.add(hitBox)
+
+ scene.add(group)
+
+ return {
+ seat,
+ hitBox,
+ ring,
+ group,
+ // Der Anker steigt mit der Tiefe: weiter hinten sitzende Plaketten hängen
+ // höher. Ohne diese Staffelung laufen sie am Fluchtpunkt ineinander, weil
+ // die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
+ labelAnchor: new THREE.Vector3(
+ seat.x,
+ SEATED.headY + SEATED.headRadius + 0.16 + Math.min(0.78, Math.max(0, (Math.abs(seat.z) - 1.75) * 0.135)),
+ seat.z,
+ ),
+ }
+}
+
+// ── Aufbau ───────────────────────────────────────────────────────────────────
+
+export function createMeetingRoomScene(
+ canvas: HTMLCanvasElement,
+ seats: MeetingSeat[],
+ photos: Record,
+): MeetingRoomScene {
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.outputColorSpace = THREE.SRGBColorSpace
+
+ const scene = new THREE.Scene()
+ scene.background = new THREE.Color(ROOM_COLORS.sky)
+ // Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
+ // bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe.
+ scene.fog = new THREE.Fog(ROOM_COLORS.sky, 30, 110)
+
+ const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
+ camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
+ camera.lookAt(new THREE.Vector3(...CAMERA.lookAt))
+
+ scene.add(new THREE.AmbientLight(0xffffff, 1.45))
+ // Tageslicht von der linken Glasfront (§4 «klares natürliches Tageslicht»)
+ const sun = new THREE.DirectionalLight(0xfff6e8, 1.5)
+ sun.position.set(-6, 5, -2)
+ scene.add(sun)
+ // Aufheller von der geschlossenen Wand, damit rechts nichts absäuft
+ const bounce = new THREE.DirectionalLight(0xdfe6ee, 0.5)
+ bounce.position.set(5, 2, 3)
+ scene.add(bounce)
+
+ const loader = new THREE.TextureLoader()
+
+ addBaselBackdrop(scene, loader)
+ addRoomShell(scene)
+ addGlassFacades(scene)
+ addTable(scene)
+
+ const handles = seats.map((seat, i) =>
+ addSeatedAgent(scene, loader, seat, photos[seat.id], ROOM_COLORS.suits[i % ROOM_COLORS.suits.length]),
+ )
+
+ const baseCam = camera.position.clone()
+ const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
+
+ function render() {
+ renderer.render(scene, camera)
+ }
+
+ return {
+ scene,
+ camera,
+ renderer,
+ seats: handles,
+
+ applyParallax(dx, dy) {
+ camera.position.set(
+ baseCam.x + dx * CAMERA.parallax,
+ baseCam.y + dy * CAMERA.parallax * 0.6,
+ baseCam.z,
+ )
+ camera.lookAt(lookTarget)
+ },
+
+ setSize(width, height) {
+ renderer.setSize(width, height, false)
+ const aspect = width / Math.max(height, 1)
+ camera.aspect = aspect
+ // Schmale Fenster brauchen einen weiteren Bildwinkel: bei festem Winkel
+ // schneidet das hohe Format den Tisch links und rechts ab. Statt einer
+ // eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
+ // Kamera, bis der Raum wieder hineinpasst.
+ camera.fov = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
+ camera.updateProjectionMatrix()
+ },
+
+ render,
+
+ setHovered(id) {
+ for (const h of handles) {
+ const on = h.seat.id === id
+ ;(h.ring.material as THREE.MeshBasicMaterial).color.set(
+ on ? ROOM_COLORS.headRingHover : ROOM_COLORS.headRing,
+ )
+ // Leichtes Anheben statt Vergrössern: eine skalierte Person wirkt
+ // sofort falsch, ein Aufrichten liest sich als Zuwendung.
+ h.group.position.y = on ? 0.035 : 0
+ }
+ },
+
+ dispose() {
+ scene.traverse(obj => {
+ if (obj instanceof THREE.Mesh) {
+ obj.geometry.dispose()
+ const mat = obj.material
+ const mats = Array.isArray(mat) ? mat : [mat]
+ for (const m of mats) {
+ const withMap = m as THREE.Material & { map?: THREE.Texture | null }
+ withMap.map?.dispose()
+ m.dispose()
+ }
+ }
+ })
+ renderer.dispose()
+ },
+ }
+}
diff --git a/src/components/staff/meetingRoomSeats.ts b/src/components/staff/meetingRoomSeats.ts
new file mode 100644
index 0000000..d3c3804
--- /dev/null
+++ b/src/components/staff/meetingRoomSeats.ts
@@ -0,0 +1,81 @@
+import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
+import { AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
+
+/**
+ * Sitzordnung im 3D-Meetingraum (Runde 6, §5/§6/§7).
+ *
+ * Eine einzige Tabelle bestimmt, wer wo sitzt. Namen, Funktionen, Porträts und
+ * Zielseiten kommen dabei nicht von hier, sondern aus `AGENT_WORKSPACES` —
+ * derselben zentralen Konfiguration, die Menü und Seitenköpfe verwenden. Hier
+ * steht ausschliesslich die Geometrie (§24, Schritt 7: nichts doppelt pflegen).
+ *
+ * Koordinaten sind Meter im Weltsystem der Szene:
+ * - x: negativ = links vom Betrachter, positiv = rechts
+ * - z: 0 am Kopfende des Betrachters, negativ = in den Raum hinein
+ *
+ * Die Agenten wechseln die Tischseite in der Reihenfolge der Konfiguration
+ * (Ferdi links, Bruno rechts, Livia links, …). Das verteilt sie gleichmässig,
+ * hält die Abstände gross genug, dass sich niemand überlappt, und macht den
+ * Arbeitsablauf auch am Tisch ablesbar.
+ */
+
+/** Auf welcher Tischseite ein Platz liegt. */
+export const SeatSide = {
+ LEFT: 'LEFT',
+ RIGHT: 'RIGHT',
+ /** Kopfende gegenüber dem Betrachter — nur Thomas. */
+ HEAD: 'HEAD',
+} as const
+export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
+
+export interface MeetingSeat {
+ /** Agenten-ID; identisch mit `AGENT_PHOTOS` und den Mockdaten. */
+ id: string
+ name: string
+ role: string
+ /** Zielseite des Klicks. */
+ path: string
+ side: SeatSide
+ /** Sitzposition in Metern. */
+ x: number
+ z: number
+}
+
+/**
+ * Abstand der Plätze entlang des Tisches, in Metern. Bewusst grosszügig: bei
+ * engerer Staffelung überschneiden sich in der Perspektive die Köpfe der
+ * hinteren Plätze mit denen der vorderen (§5 «keine Personen überlappen»).
+ */
+const SEAT_Z = [-1.75, -3.55, -5.35]
+
+/** Seitlicher Abstand von der Tischmitte — knapp aussen an der Tischkante. */
+const SEAT_X = 1.32
+
+/** Kopfende gegenüber: hinter der Tischkante, vor der Glasfront. */
+const HEAD_Z = -8.15
+
+export const MEETING_SEATS: MeetingSeat[] = [
+ ...AGENT_WORKSPACES.map((agent, i) => {
+ const left = i % 2 === 0
+ return {
+ id: agent.id,
+ name: agent.name,
+ role: agent.role,
+ path: agent.path,
+ side: left ? SeatSide.LEFT : SeatSide.RIGHT,
+ x: left ? -SEAT_X : SEAT_X,
+ // Die Plätze rücken paarweise nach hinten: links und rechts versetzt,
+ // damit über die Tischmitte hinweg niemand hinter einem Kopf verschwindet.
+ z: SEAT_Z[Math.floor(i / 2)] - (left ? 0 : 0.9),
+ }
+ }),
+ {
+ id: AGENT_SUPERVISOR.id,
+ name: AGENT_SUPERVISOR.name,
+ role: AGENT_SUPERVISOR.role,
+ path: AGENT_SUPERVISOR.path,
+ side: SeatSide.HEAD,
+ x: 0,
+ z: HEAD_Z,
+ },
+]
diff --git a/src/components/staff/roomBackdrop.ts b/src/components/staff/roomBackdrop.ts
deleted file mode 100644
index 4d21740..0000000
--- a/src/components/staff/roomBackdrop.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import buero from '../../assets/staff/buero.jpg'
-
-/**
- * Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
- *
- * Genau eine Stelle bestimmt, welches Bild hinter den Personenkarten liegt.
- * Ein Austausch ist damit ein Einzeiler; die Herkunft steht in `HERKUNFT.md`
- * neben der Bilddatei.
- *
- * Das Bild ist Umgebung, nicht Motiv: es zeigt ein Büro, damit die digitalen
- * Mitarbeitenden einen Arbeitsplatz haben statt eines leeren Hintergrunds. Der
- * Verlauf darüber nimmt ihm den grössten Teil des Kontrasts — sonst
- * konkurriert jeder Bürostuhl mit den Gesichtern und der Schrift davor.
- */
-export interface RoomBackdrop {
- src: string
- /** Bildausschnitt bei `object-fit: cover`. */
- focus: string
- /** Verlauf über dem Bild: Lesbarkeit und Übergang zur Objektliste darunter. */
- scrim: string
-}
-
-export const ROOM_BACKDROP: RoomBackdrop = {
- src: buero,
- focus: '50% 58%',
- scrim: [
- // Grundton: das Büro tritt weit zurück, die Karten treten hervor.
- 'linear-gradient(180deg, rgba(9,14,23,0.84) 0%, rgba(9,14,23,0.76) 52%, rgba(9,14,23,0.92) 100%)',
- // Warmes Streiflicht von links, damit die Fläche nicht tot wirkt.
- 'radial-gradient(85% 120% at 10% 6%, rgba(184,151,90,0.18) 0%, rgba(184,151,90,0) 58%)',
- ].join(', '),
-}
diff --git a/src/components/team/agentPhotos.ts b/src/components/team/agentPhotos.ts
index ce05b3c..b238e5c 100644
--- a/src/components/team/agentPhotos.ts
+++ b/src/components/team/agentPhotos.ts
@@ -50,6 +50,11 @@ import gian from '../../assets/team/gian.jpg'
import ida from '../../assets/team/ida.jpg'
import jana from '../../assets/team/jana.jpg'
import zeno from '../../assets/team/zeno.jpg'
+// Thomas ist in Runde 6 dazugekommen und hat kein Porträt aus dem
+// Konzeptdokument. Statt den Kopf eines bestehenden Mitarbeitenden
+// zweitzuverwenden — jeder Kopf gehört genau einer Person — trägt er ein eigenes
+// Porträt. Herkunft und Lizenz stehen in `assets/team/HERKUNFT.md`.
+import thomas from '../../assets/team/thomas.jpg'
export const AGENT_PHOTOS: Record = {
bruno,
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record = {
ida,
jana,
zeno,
+ thomas,
}
diff --git a/src/domain/agentPresence.ts b/src/domain/agentPresence.ts
deleted file mode 100644
index 9cffb86..0000000
--- a/src/domain/agentPresence.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { AgentWorkload } from './agentWorkload'
-
-/**
- * Property On — ein digitaler Mitarbeitender, so wie er auf der Startseite
- * auftritt (Runde 5).
- *
- * Zusammengesetzt aus drei Quellen, weil Menschlichkeit aus dem Zusammenspiel
- * entsteht und nicht aus einem einzelnen Feld:
- *
- * - **Personalien** — Name, Funktion, Abteilung, Personalnummer. Wer eine
- * Personalnummer hat, ist Personal und kein Werkzeug.
- * - **Auslastung** — woran er gerade sitzt, in seinen eigenen Worten.
- * - **Letzte Handlung** — was er zuletzt getan hat, mit Zeitangabe. Das ist
- * der Beleg, dass wirklich jemand arbeitet; ohne ihn bleibt jede Zusage
- * eine Behauptung.
- */
-export interface AgentPresence {
- agentId: string
- name: string
- role: string
- /** Abteilung laut Personalblatt; leer, solange kein Dossier vorliegt. */
- department: string
- /** Personalnummer laut Personalblatt; leer, solange kein Dossier vorliegt. */
- personnelNumber: string
- /** Zielseite des Klicks auf die Karte. */
- path: string
- /** true, solange der Mitarbeitende nicht pausiert ist. */
- onDuty: boolean
- /**
- * Was der Mitarbeitende selbst entscheiden darf — «Autonom», «Freigabe
- * erforderlich» oder «Nur Vorschlag». Die Stufe ist das stärkste Merkmal des
- * Ansatzes: sie sagt, wie viel Verantwortung jemand trägt, und trennt
- * Personal von einer Automatisierung.
- */
- autonomy: string
- workload: AgentWorkload
- /** Zuletzt protokollierte Handlung — fehlt, wenn noch nichts vorliegt. */
- lastAction?: {
- /** Was getan wurde, als ganzer Satz aus dem Protokoll. */
- title: string
- /** Wann, in Alltagssprache: «vor 12 Min.», «gestern 16:40». */
- when: string
- }
-}
diff --git a/src/hooks/useAgentPresence.ts b/src/hooks/useAgentPresence.ts
deleted file mode 100644
index 28fa5bc..0000000
--- a/src/hooks/useAgentPresence.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useMemo } from 'react'
-import { useTeamAgents } from './useTeamAgents'
-import { useAgentProtocol } from './useAgentProtocol'
-import { useAgentWorkload } from './useAgentWorkload'
-import { buildAgentPresence } from '../services/agentPresenceService'
-import type { AgentPresence } from '../domain/agentPresence'
-
-/**
- * Die Belegschaft, wie sie auf der Startseite auftritt (Runde 5).
- *
- * Der Hook sammelt Personalblätter, Protokoll und Auslastung ein; was daraus
- * wird, entscheidet `agentPresenceService` (CLAUDE.md §4.2).
- */
-export function useAgentPresence(): { data: AgentPresence[]; isLoading: boolean } {
- const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
- const { data: protocol = [], isLoading: protocolLoading } = useAgentProtocol()
- const { data: workload, isLoading: workloadLoading } = useAgentWorkload()
-
- const data = useMemo(
- () => buildAgentPresence({ agents, protocol, workload }),
- [agents, protocol, workload],
- )
-
- return { data, isLoading: agentsLoading || protocolLoading || workloadLoading }
-}
diff --git a/src/lib/agentWorkspaces.ts b/src/lib/agentWorkspaces.ts
index 886529c..edcba78 100644
--- a/src/lib/agentWorkspaces.ts
+++ b/src/lib/agentWorkspaces.ts
@@ -33,3 +33,21 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
return AGENT_WORKSPACES.find(a => a.id === id)
}
+
+/**
+ * Thomas — Personalverwalter und Vorgesetzter der fünf Agenten (Runde 6, §7).
+ *
+ * Bewusst nicht Teil von `AGENT_WORKSPACES`: Thomas hat keine eigene
+ * Arbeitsseite, sondern steht für die Adminfläche selbst. Sein Ziel ist «Meine
+ * Agenten» mit den Bereichen Personalverwaltung, Kanäle & Systeme und
+ * Bearbeitungsverlauf. Stünde er in derselben Liste, erschiene er als sechster
+ * Eintrag im Menü und als sechster Mitarbeitender in der Belegschaft — beides
+ * wäre falsch (§13: «Thomas muss nicht zwingend nochmals als normaler
+ * Unteragent dargestellt werden»).
+ */
+export const AGENT_SUPERVISOR: AgentWorkspace = {
+ id: 'thomas',
+ name: 'Thomas',
+ role: 'Personalverwalter',
+ path: ROUTES.SUPPLY.TEAM,
+}
diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx
index 4d34d7c..aeb0e35 100644
--- a/src/pages/supply/Properties.tsx
+++ b/src/pages/supply/Properties.tsx
@@ -4,7 +4,7 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PageHeader } from '../../components/layout'
import { ViewToggle } from '../../components/shared'
-import { AgentStaffOverview } from '../../components/staff'
+import { MeetingRoom3D } from '../../components/staff'
import { useProperties } from '../../hooks/useProperties'
import { useActiveInquiries } from '../../hooks/useInquiries'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
@@ -95,13 +95,14 @@ export default function Properties() {
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
return (
- // Runde 5, §2.3: Die Startseite scrollt als Ganzes. Die Belegschaft steht
- // oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
- // beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
+ // Runde 6, §1.1/§17: Die Startseite ist die Landing Page und scrollt als
+ // Ganzes. Oben der Meetingraum, darunter «Meine Objekte» mit dem
+ // unveränderten Objektbestand. Der Meetingraum bemisst seine Höhe so, dass
+ // Titel und Filterbalken beim Einstieg gerade noch sichtbar bleiben.
// Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken
// im selben Bild und würde genau das verhindern.
-
+
new Date(NOW - ms).toISOString()
-
-function workload(agentId: string): AgentWorkload {
- return { agentId, count: 0, label: 'nichts offen', tone: WorkloadTone.CALM, voice: 'Alles ruhig.' }
-}
-
-const WORKLOAD = Object.fromEntries(
- ['ferdi', 'bruno', 'livia', 'nora', 'sina'].map(id => [id, workload(id)]),
-)
-
-function input(partial: Partial = {}): AgentPresenceInput {
- return { agents: [], protocol: [], workload: WORKLOAD, ...partial }
-}
-
-// Nur die Felder, die der Dienst liest.
-const dossier = (id: string, extra: Record = {}) =>
- ({
- id,
- department: 'Bewirtschaftung',
- personnelNumber: 'PO-TEST-01',
- status: AgentStatus.ACTIVE,
- autonomyLevel: 'AUTONOMOUS',
- ...extra,
- }) as AgentPresenceInput['agents'][number]
-
-const entry = (agentId: string, msAgo: number, title: string) =>
- ({ agentId, timestamp: ago(msAgo), title }) as AgentPresenceInput['protocol'][number]
-
-describe('Zeitangabe in Alltagssprache', () => {
- it('nennt frische Ereignisse in Minuten', () => {
- expect(formatRelativeTime(ago(12 * MINUTE), NOW)).toBe('vor 12 Min.')
- })
-
- it('wechselt nach einer Stunde auf Stunden, mit korrekter Einzahl', () => {
- expect(formatRelativeTime(ago(1 * HOUR + MINUTE), NOW)).toBe('vor 1 Stunde')
- expect(formatRelativeTime(ago(5 * HOUR), NOW)).toBe('vor 5 Stunden')
- })
-
- it('nennt ab gestern die Uhrzeit — «vor 19 Stunden» ordnet niemand einem Arbeitstag zu', () => {
- expect(formatRelativeTime(ago(30 * HOUR), NOW)).toMatch(/^gestern \d{2}:\d{2}$/)
- })
-
- it('nennt weiter zurückliegende Ereignisse mit Datum', () => {
- expect(formatRelativeTime(ago(9 * DAY), NOW)).toMatch(/^\d{2}\.\d{2}\. \d{2}:\d{2}$/)
- })
-
- it('bleibt bei unbrauchbarem Zeitstempel stumm statt «Invalid Date» zu zeigen', () => {
- expect(formatRelativeTime('kein datum', NOW)).toBe('')
- })
-})
-
-describe('Belegschaft der Startseite', () => {
- it('führt die fünf Agenten in verbindlicher Reihenfolge', () => {
- const staff = buildAgentPresence(input(), NOW)
- expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
- })
-
- it('übernimmt Personalien aus dem Personalblatt', () => {
- const staff = buildAgentPresence(
- input({ agents: [dossier('ferdi', { department: 'Bewirtschaftung', personnelNumber: 'PO-ZD-02' })] }),
- NOW,
- )
- const ferdi = staff.find(p => p.agentId === 'ferdi')!
- expect(ferdi.department).toBe('Bewirtschaftung')
- expect(ferdi.personnelNumber).toBe('PO-ZD-02')
- expect(ferdi.autonomy).toBe('Autonom')
- })
-
- it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => {
- const staff = buildAgentPresence(
- input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }),
- NOW,
- )
- expect(staff.find(p => p.agentId === 'sina')!.onDuty).toBe(false)
- // Wer kein Dossier hat, gilt als im Dienst — sonst stünde die halbe
- // Belegschaft nur deshalb still, weil Stammdaten fehlen.
- expect(staff.find(p => p.agentId === 'bruno')!.onDuty).toBe(true)
- })
-
- it('nimmt je Mitarbeitendem den jüngsten Protokolleintrag', () => {
- const staff = buildAgentPresence(
- input({
- protocol: [
- entry('livia', 5 * DAY, 'Alter Eintrag'),
- entry('livia', 20 * MINUTE, 'Jüngster Eintrag'),
- entry('livia', 2 * DAY, 'Mittlerer Eintrag'),
- ],
- }),
- NOW,
- )
- const livia = staff.find(p => p.agentId === 'livia')!
- expect(livia.lastAction?.title).toBe('Jüngster Eintrag')
- expect(livia.lastAction?.when).toBe('vor 20 Min.')
- })
-
- it('lässt die letzte Handlung offen, statt eine zu erfinden', () => {
- const staff = buildAgentPresence(input(), NOW)
- expect(staff.every(p => p.lastAction === undefined)).toBe(true)
- })
-
- it('ignoriert Protokolleinträge ohne Zuordnung — sie gehören keinem Mitarbeitenden', () => {
- const staff = buildAgentPresence(
- input({ protocol: [{ timestamp: ago(MINUTE), title: 'Verbindung entfernt' } as AgentPresenceInput['protocol'][number]] }),
- NOW,
- )
- expect(staff.every(p => p.lastAction === undefined)).toBe(true)
- })
-})
diff --git a/src/services/agentPresenceService.ts b/src/services/agentPresenceService.ts
deleted file mode 100644
index b0dcf5f..0000000
--- a/src/services/agentPresenceService.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import type { AgentPresence } from '../domain/agentPresence'
-import type { AgentWorkload } from '../domain/agentWorkload'
-import type { AgentProtocolEntry } from '../domain/agentProtocol'
-import type { TeamAgent } from '../domain/teamAgent'
-import { AgentStatus } from '../domain/teamAgent'
-import { AGENT_WORKSPACES } from '../lib/agentWorkspaces'
-import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
-
-/**
- * Setzt die Belegschaftsansicht der Startseite zusammen (Runde 5).
- *
- * Reine Geschäftslogik ausserhalb von React (CLAUDE.md §4.2): welcher Eintrag
- * als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
- * sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
- * Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
- */
-
-const MINUTE = 60 * 1000
-const HOUR = 60 * MINUTE
-const DAY = 24 * HOUR
-
-/**
- * Zeitangabe in Alltagssprache.
- *
- * Bewusst grob: «vor 12 Min.» ist für einen Menschen brauchbarer als
- * «14:32:07». Ab gestern steht die Uhrzeit dabei, weil «vor 19 Stunden»
- * niemand in einen Arbeitstag einordnen kann.
- */
-export function formatRelativeTime(iso: string, now: number): string {
- const t = new Date(iso).getTime()
- if (!Number.isFinite(t)) return ''
-
- const diff = now - t
- if (diff < 0) return 'gerade eben'
- if (diff < MINUTE) return 'gerade eben'
- if (diff < HOUR) return `vor ${Math.floor(diff / MINUTE)} Min.`
- if (diff < DAY) {
- const hours = Math.floor(diff / HOUR)
- return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
- }
-
- const date = new Date(t)
- const time = date.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
- if (diff < 2 * DAY) return `gestern ${time}`
- return date.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ` ${time}`
-}
-
-export interface AgentPresenceInput {
- agents: TeamAgent[]
- protocol: AgentProtocolEntry[]
- workload: Record
-}
-
-/**
- * @param now Stichzeitpunkt in Millisekunden — als Parameter, nicht aus der
- * Systemuhr, damit das Ergebnis reproduzierbar bleibt.
- */
-export function buildAgentPresence(
- { agents, protocol, workload }: AgentPresenceInput,
- now: number = Date.now(),
-): AgentPresence[] {
- // Jüngster Protokolleintrag je Mitarbeitendem. Ein Durchgang über die Liste
- // statt einer Suche je Agent — das Protokoll wächst, die Belegschaft nicht.
- const latest = new Map()
- for (const entry of protocol) {
- if (!entry.agentId) continue
- const known = latest.get(entry.agentId)
- if (!known || entry.timestamp > known.timestamp) latest.set(entry.agentId, entry)
- }
-
- const byId = new Map(agents.map(a => [a.id, a]))
- const roster = AGENT_WORKSPACES
-
- return roster.map(member => {
- const dossier = byId.get(member.id)
- const action = latest.get(member.id)
-
- return {
- agentId: member.id,
- name: member.name,
- role: member.role,
- department: dossier?.department ?? '',
- personnelNumber: dossier?.personnelNumber ?? '',
- path: member.path,
- // Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
- // soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
- onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
- autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : '',
- workload: workload[member.id],
- lastAction: action
- ? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
- : undefined,
- }
- })
-}