feat(startseite): echter 3D-Meetingraum, Navigation in die Topbar
Anpassungsrunde 6. Die Kartendarstellung aus Runde 5 ist ersetzt: die Startseite zeigt oben einen räumlichen Meetingraum aus der Sicht des Betrachters, der selbst am Kopfende des Tisches sitzt. **3D statt Kulisse.** Zwei Versuche mit CSS-Flächen sind daran gescheitert, dass gezeichnete Wände neben fotografierten Porträts erkennbar gezeichnet bleiben. Diese Runde nutzt three.js — die einzige neue Abhängigkeit, und §19 lässt sie ausdrücklich zu, wenn eine schlanke 3D-Lösung die Anforderung trägt. Keine Engine darüber hinaus: kein React-Three-Fiber, keine Physik, keine Shader, keine Post-Processing-Kette, keine Orbit-Steuerung. Der Raum: Boden, Decke, rechts eine geschlossene Wand mit Eichenpaneel und einem schmalen Streifen im Markenblau, links und gegenüber bodentiefe Glasfassaden mit Pfosten. Draussen tragen die beiden Referenzfotos aus dem Auftrag die Basler Aussicht — vorne Rhein, Altstadt und Münster, links der Rheinverlauf mit der Wettsteinbrücke. Die Wasserlinie beider Kulissen liegt auf derselben Welthöhe, damit der Blick nach vorne und nach links zusammengehört und nicht wie zwei zusammengesetzte Bilder wirkt (§4). Am Tisch sitzen alle fünf bestehenden Agenten mit ihren bestehenden Porträts, Namen, Funktionen und Zielseiten — alles aus `AGENT_WORKSPACES`, damit nichts doppelt gepflegt wird (§24, Schritt 7). Neu ist Thomas, Personalverwalter, vis-à-vis am gegenüberliegenden Kopfende; sein Klick führt in die bestehende Ansicht «Meine Agenten». Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für eine feste Szene verschwendete Rechenzeit (§21). Die Kamera folgt der Maus um wenige Zentimeter, mehr nicht (§20). Die Beschriftungen sind HTML über dem Bild, nicht Textur in der Szene: als Textur wären sie bei Tiefe unlesbar. Ein Entzerrungsdurchgang nach der Projektion hebt Plaketten so weit an, bis keine mehr überlappt — gerechnet und nicht handverlesen, damit es auf jeder Fenstergrösse hält. Bei Hover kommt eine Zeile mit der aktuellen Auslastung dazu; ruhend bleiben es Name und Funktion. **Navigation.** Die linke Seitenleiste ist gelöscht, nicht ausgeblendet: samt Breitenreservierung, Einklapplogik und mobilem Drawer. Der Inhalt beginnt überall bei x=0. An der Stelle des früheren Profilsymbols steht neben der Glocke ein Menüsymbol; das Klappmenü öffnet rechts an der Kopfzeile nach unten und führt Startseite, Meine Agenten samt aller Agenten als eingerückte, rechtsbündige Unterpunkte, und Profil mit Profil, Einstellungen, Produkttour und Abmelden. Alle Aktionen und Routen sind die bestehenden. Entfallen sind die Kartenkomponenten aus Runde 5 samt Präsenzschicht. Geprüft (§25): 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925). Im Browser 430–2560 px: keine alte Sidebar, kein leerer Streifen links, sechs Beschriftungen ohne Überlappung, alle sechs Agenten per Maus und per Tastatur erreichbar und auf der richtigen Subpage, Menü öffnet und schliesst per Klick und Escape, alle Menürouten korrekt, alle bestehenden Seiten laden, kein horizontaler Overflow, keine Konsolenfehler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e55be64aeb
commit
bf453d2b69
30 files changed
+1408
-1412
No files matched your search
@@ -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).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 477 KiB |
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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 = (
|
||||
<Sidebar
|
||||
collapsed={isMobile ? false : sidebarCollapsed}
|
||||
activeWorkspace={activeWorkspace}
|
||||
allowedWorkspaces={allowedWorkspaces}
|
||||
onWorkspaceClick={handleWorkspaceClick}
|
||||
onToggle={isMobile ? closeMobileMenu : toggleSidebar}
|
||||
onClose={isMobile ? closeMobileMenu : undefined}
|
||||
userName={userName}
|
||||
orgName={orgName}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Desktop: permanent sidebar */}
|
||||
{!isMobile && sidebarContent}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||
<TopBar pathname={location.pathname} />
|
||||
|
||||
{/* Mobile: temporary drawer */}
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
open={isMobileOpen}
|
||||
onClose={closeMobileMenu}
|
||||
variant="temporary"
|
||||
ModalProps={{ keepMounted: true }}
|
||||
slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<TopBar
|
||||
pathname={location.pathname}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={openMobileMenu}
|
||||
/>
|
||||
|
||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<RightContextPanel />
|
||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<RightContextPanel />
|
||||
<ToastProvider />
|
||||
<WelcomeDialog />
|
||||
</Box>
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width,
|
||||
minWidth: width,
|
||||
flexShrink: 0,
|
||||
height: '100vh',
|
||||
backgroundColor: SIDEBAR_BG,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
px: collapsed ? 0 : 2.5,
|
||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
||||
>
|
||||
PM
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
||||
>
|
||||
Property
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: DS_BRAND.light,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontSize: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Match
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */}
|
||||
{visibleWorkspaces.length > 1 && <Box
|
||||
sx={{
|
||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: collapsed ? '1fr' : `repeat(${visibleWorkspaces.length}, 1fr)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{visibleWorkspaces.map((ws, idx) => {
|
||||
const wsConfig = WORKSPACE_CONFIG[ws]
|
||||
const Icon = wsConfig.icon
|
||||
const isActive = ws === activeWorkspace
|
||||
|
||||
const tabContent = (
|
||||
<Box
|
||||
onClick={() => { 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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon size={15} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{wsConfig.label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
return collapsed ? (
|
||||
<Tooltip key={ws} title={wsConfig.label} placement="right">
|
||||
{tabContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box key={ws}>{tabContent}</Box>
|
||||
)
|
||||
})}
|
||||
</Box>}
|
||||
|
||||
{/* Nav items */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
|
||||
{config.navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
const navContent = (
|
||||
<NavLink
|
||||
to={item.path}
|
||||
style={{ textDecoration: 'none', display: 'block' }}
|
||||
onClick={() => onClose?.()}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
|
||||
pr: collapsed ? 0 : 1.5,
|
||||
py: 0.75,
|
||||
mx: 0.5,
|
||||
borderRadius: 1,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': { backgroundColor: NAV_HOVER_BG },
|
||||
transition: 'border-color 0.15s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||
{!collapsed && (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: '0.875rem',
|
||||
letterSpacing: '-0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
{item.path === '/demand/compare' && compareCount > 0 && (
|
||||
<Box sx={{
|
||||
minWidth: 18, height: 18, borderRadius: '9px',
|
||||
bgcolor: DS_ACCENT.warning.main, color: 'white',
|
||||
fontSize: '0.65rem', fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
px: 0.5, flexShrink: 0,
|
||||
}}>
|
||||
{compareCount}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
// 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 ? (
|
||||
<Tooltip key={item.path} title={item.label} placement="right">
|
||||
<Box>{navContent}</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box key={item.path}>
|
||||
{navContent}
|
||||
{inSection && (
|
||||
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, mt: 0.25, mb: 0.5 }}>
|
||||
{subItems.map((child) => (
|
||||
<Box component="li" key={child.path}>
|
||||
<NavLink
|
||||
to={child.path}
|
||||
style={{ textDecoration: 'none', display: 'block' }}
|
||||
onClick={() => onClose?.()}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
pl: child.agentId ? 2.5 : 4.75,
|
||||
pr: 1.5,
|
||||
py: child.agentId ? 0.625 : 0.5,
|
||||
mx: 0.5,
|
||||
borderRadius: 1,
|
||||
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
|
||||
'&:hover': { backgroundColor: NAV_HOVER_BG },
|
||||
transition: 'border-color 0.15s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{child.agentId && (
|
||||
<AgentAvatar
|
||||
agent={{ id: child.agentId, name: child.label, role: child.role ?? '' }}
|
||||
size={26}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: '0.8125rem',
|
||||
letterSpacing: '-0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{child.label}
|
||||
</Typography>
|
||||
{child.role && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: isActive ? TEXT_MUTED : '#64748b',
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.3,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{child.role}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Bottom section */}
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: `1px solid ${DIVIDER_COLOR}`,
|
||||
px: collapsed ? 0 : 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{!collapsed && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: DS_NEUTRAL.gold,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getUserInitials(userName)}
|
||||
</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: TEXT_WHITE,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.8125rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: DS_SLATE[500],
|
||||
fontSize: '0.7rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{orgName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
onClick={onToggle}
|
||||
size="small"
|
||||
sx={{ color: DS_SLATE[500], flexShrink: 0 }}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box
|
||||
component="header"
|
||||
@@ -50,24 +39,17 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
px: { xs: 1.5, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{isMobile && (
|
||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||
<Menu size={20} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{/* Links bleibt frei — die Navigation liegt vollständig rechts. */}
|
||||
<Box sx={{ flexShrink: 0 }} />
|
||||
|
||||
<AgentChatStickyBar />
|
||||
|
||||
{/* Right side */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<OrganizationContextBadge />
|
||||
</Box>
|
||||
<NotificationButton />
|
||||
<UserMenu />
|
||||
<GlobalNavMenu />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, ButtonBase, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
|
||||
import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
PROPERTY_MANAGER: 'Property Manager',
|
||||
DEMAND_USER: 'Demand User',
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Navigation in der Kopfzeile (Runde 6, §11–§16).
|
||||
*
|
||||
* Sie ersetzt die entfernte Seitenleiste und das frühere Profilsymbol. Ein
|
||||
* einziges Menüsymbol neben der Glocke öffnet ein Klappmenü, das rechts an der
|
||||
* Kopfzeile ausgerichtet nach unten aufgeht.
|
||||
*
|
||||
* Aufbau: «Startseite», «Meine Agenten» mit allen Agenten als eingerückte,
|
||||
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
|
||||
* die vorher im Profilmenü lagen.
|
||||
*
|
||||
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen
|
||||
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
|
||||
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
|
||||
* Schritt 7).
|
||||
*
|
||||
* `Popover` statt `Menu`: Ein `Menu` erwartet gleichrangige `MenuItem`-Kinder
|
||||
* und übernimmt Tastaturnavigation über eine flache Liste. Hier gibt es zwei
|
||||
* Ebenen mit unterschiedlicher Ausrichtung — das Popover lässt diese Struktur
|
||||
* zu und bringt Klick-ausserhalb und Escape von sich aus mit.
|
||||
*/
|
||||
export function GlobalNavMenu() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const { currentUser, logout } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const open = !!anchorEl
|
||||
|
||||
const close = () => setAnchorEl(null)
|
||||
|
||||
/** Menüpunkt und Ziel gehören zusammen: erst schliessen, dann navigieren. */
|
||||
const go = (path: string) => {
|
||||
close()
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
close()
|
||||
logout()
|
||||
navigate('/auth/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Menü">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => setAnchorEl(open ? null : e.currentTarget)}
|
||||
aria-label="Menü öffnen"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
sx={{ color: DS_TEXT.primary }}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
mt: 1,
|
||||
width: 320,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuSection>
|
||||
<MainItem label={HOME_NAV_LABEL} onClick={() => go(ROUTES.SUPPLY.PROPERTIES)} />
|
||||
</MenuSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuSection>
|
||||
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
|
||||
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
|
||||
{AGENT_WORKSPACES.map(agent => (
|
||||
<SubItem key={agent.id} onClick={() => go(agent.path)}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
<AgentAvatar agent={agent} size={26} />
|
||||
</SubItem>
|
||||
))}
|
||||
</MenuSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuSection>
|
||||
<MainItem label="Profil" onClick={() => showProfileHint(showToast, close)} />
|
||||
{currentUser && (
|
||||
<Typography
|
||||
sx={{
|
||||
px: 2, pb: 0.75, textAlign: 'right',
|
||||
fontSize: '0.6875rem', color: DS_TEXT.muted,
|
||||
}}
|
||||
>
|
||||
{currentUser.name} · {ROLE_LABELS[currentUser.role] ?? currentUser.role}
|
||||
{currentUser.organizationName ? ` · ${currentUser.organizationName}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<SubItem onClick={() => showProfileHint(showToast, close)}>
|
||||
<ProfileLabel icon={<User size={15} />} label="Profil" />
|
||||
</SubItem>
|
||||
<SubItem onClick={() => { close(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
||||
<ProfileLabel icon={<Settings size={15} />} label="Einstellungen" />
|
||||
</SubItem>
|
||||
<SubItem
|
||||
onClick={() => {
|
||||
// Bestehende Logik: die Tour zeigt sich erneut, sobald die Merkung
|
||||
// entfernt ist. Nicht neu gebaut (§16).
|
||||
close()
|
||||
localStorage.removeItem('property_match_welcomed')
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
<ProfileLabel icon={<HelpCircle size={15} />} label="Produkttour starten" />
|
||||
</SubItem>
|
||||
<SubItem onClick={handleLogout}>
|
||||
<ProfileLabel icon={<LogOut size={15} />} label="Abmelden" />
|
||||
</SubItem>
|
||||
</MenuSection>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function showProfileHint(showToast: (m: string, t: 'info') => void, close: () => void) {
|
||||
close()
|
||||
showToast('Profilseite ist in Kürze verfügbar.', 'info')
|
||||
}
|
||||
|
||||
function MenuSection({ children }: { children: React.ReactNode }) {
|
||||
return <Box sx={{ py: 1 }}>{children}</Box>
|
||||
}
|
||||
|
||||
/** Hauptpunkt: linksbündig, kräftig — er trägt die Hierarchie. */
|
||||
function MainItem({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
px: 2, py: 1,
|
||||
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_BRAND.main }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterpunkt: kleiner, eingerückt und am rechten Rand ausgerichtet (§14/§16).
|
||||
* Die Rechtsausrichtung ist die Vorgabe — sie trennt die Ebenen auch ohne
|
||||
* Einrückungslinien sichtbar voneinander.
|
||||
*/
|
||||
function SubItem({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
pl: 4, pr: 2, py: 0.75,
|
||||
textAlign: 'right',
|
||||
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileLabel({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.primary }}>{label}</Typography>
|
||||
<Box sx={{ display: 'flex', color: DS_TEXT.secondary }}>{icon}</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 | HTMLElement>(null)
|
||||
const { currentUser, logout } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const showToast = useToastStore((s) => s.showToast)
|
||||
|
||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||
setAnchorEl(e.currentTarget)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
handleClose()
|
||||
logout()
|
||||
navigate('/auth/login')
|
||||
}
|
||||
|
||||
const initials = getUserInitials(currentUser?.name ?? 'U')
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
||||
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: DS_BRAND.main }}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={!!anchorEl}
|
||||
onClose={handleClose}
|
||||
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
{/* User info header */}
|
||||
{currentUser && (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{currentUser.organizationName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
|
||||
<ListItemIcon><User size={16} /></ListItemIcon>
|
||||
Profil
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
||||
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
||||
Einstellungen
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); localStorage.removeItem('property_match_welcomed'); window.location.reload() }}>
|
||||
<ListItemIcon><HelpCircle size={16} /></ListItemIcon>
|
||||
Produkttour starten
|
||||
</MenuItem>
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
||||
Abmelden
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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, string> = {
|
||||
[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 (
|
||||
<ButtonBase
|
||||
focusRipple
|
||||
onClick={() => 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 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
||||
<AgentAvatar
|
||||
agent={{ id: presence.agentId, name: presence.name, role: presence.role }}
|
||||
size={56}
|
||||
loading="eager"
|
||||
ringColor="rgba(255,255,255,0.55)"
|
||||
ringWidth={2}
|
||||
/>
|
||||
{/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', right: -1, bottom: -1,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
bgcolor: presence.onDuty ? DS_ACCENT.success.emerald : DS_NEUTRAL.stone,
|
||||
border: '2.5px solid rgba(12,18,28,0.95)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 700, fontSize: '1.0625rem', lineHeight: 1.2, color: DS_NEUTRAL.white, overflowWrap: 'break-word' }}
|
||||
>
|
||||
{presence.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
// Umbrechen statt abschneiden: «Besichtigungsassistent» passt in
|
||||
// einer schmalen Karte nicht in eine Zeile, und eine halbe
|
||||
// Funktionsbezeichnung ist keine.
|
||||
sx={{ fontSize: '0.8125rem', lineHeight: 1.3, color: 'rgba(255,255,255,0.72)', overflowWrap: 'break-word' }}
|
||||
>
|
||||
{presence.role}
|
||||
</Typography>
|
||||
{(presence.department || presence.personnelNumber) && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.3,
|
||||
color: 'rgba(255,255,255,0.42)',
|
||||
overflowWrap: 'break-word',
|
||||
// Umbrechen statt abschneiden: in einer schmalen Karte fiele
|
||||
// sonst ausgerechnet die Personalnummer weg, und die macht aus
|
||||
// einem Werkzeug einen Mitarbeitenden.
|
||||
}}
|
||||
>
|
||||
{[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */}
|
||||
{presence.autonomy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.09)',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.66)',
|
||||
}}
|
||||
>
|
||||
{presence.autonomy}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Was er dazu sagt — in der ersten Person. */}
|
||||
{workload && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{ width: 3, alignSelf: 'stretch', borderRadius: 2, bgcolor: toneColor, flexShrink: 0 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.45,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{workload.voice}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Beleg: was zuletzt tatsächlich getan wurde. */}
|
||||
<Box sx={{ mt: 'auto', pt: 1, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
|
||||
{presence.lastAction ? (
|
||||
<>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.38)',
|
||||
}}
|
||||
>
|
||||
Zuletzt · {presence.lastAction.when}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.4,
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
overflowWrap: 'break-word',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{presence.lastAction.title}
|
||||
</Typography>
|
||||
<Box
|
||||
className="staff-go"
|
||||
sx={{
|
||||
color: DS_NEUTRAL.gold,
|
||||
opacity: 0,
|
||||
transform: 'translateX(-4px)',
|
||||
transition: 'opacity 0.18s ease, transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.42)' }}>
|
||||
Noch keine Tätigkeit protokolliert.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Digitale Belegschaft"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: MIN_SCENE_PX,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<StaffBackdrop />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_PX,
|
||||
mx: 'auto',
|
||||
px: { xs: 2.5, lg: 4 },
|
||||
py: { xs: 2.5, lg: 3 },
|
||||
gap: { xs: 2, lg: 2.5 },
|
||||
}}
|
||||
>
|
||||
<StaffHeadline urgent={urgent} onDuty={onDuty} total={staff.length} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
/*
|
||||
* Ab `lg` stehen alle fünf nebeneinander in einer Reihe. Zwei
|
||||
* Reihen brauchten rund 250 Pixel mehr Höhe — auf einem Laptop war
|
||||
* das genau der Betrag, der den Bereich zum Scrollen brachte und
|
||||
* die Porträts der oberen Reihe abschnitt. Eine Reihe ist zugleich
|
||||
* das ehrlichere Bild: fünf Kolleginnen und Kollegen nebeneinander,
|
||||
* keine Hierarchie über zwei Ebenen.
|
||||
*
|
||||
* Darunter wird umbrochen, weil fünf Karten nebeneinander unter
|
||||
* 1200 Pixeln unlesbar schmal würden.
|
||||
*/
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
gap: { xs: 1.5, lg: 2 },
|
||||
'& > *': {
|
||||
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 => (
|
||||
<AgentStaffCard key={person.agentId} presence={person} onOpen={handleOpen} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: DS_NEUTRAL.gold }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: 'uppercase',
|
||||
color: DS_NEUTRAL.gold,
|
||||
}}
|
||||
>
|
||||
{onDuty} von {total} im Dienst
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1.25rem', lg: '1.6rem' },
|
||||
lineHeight: 1.2,
|
||||
color: DS_NEUTRAL.white,
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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<HTMLCanvasElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const sceneRef = useRef<MeetingRoomScene | null>(null)
|
||||
const labelRefs = useRef(new Map<string, HTMLDivElement | null>())
|
||||
const hoveredRef = useRef<string | null>(null)
|
||||
const [hovered, setHovered] = useState<string | null>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Virtueller Meetingraum mit den digitalen Mitarbeitenden"
|
||||
ref={wrapRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `clamp(${MIN_HEIGHT_PX}px, calc(100vh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
|
||||
'@supports (height: 100dvh)': {
|
||||
height: `clamp(${MIN_HEIGHT_PX}px, calc(100dvh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: hovered ? 'pointer' : 'default',
|
||||
bgcolor: DS_NEUTRAL.sidebar,
|
||||
}}
|
||||
>
|
||||
<Box component="canvas" ref={canvasRef} sx={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
|
||||
{/*
|
||||
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 (
|
||||
<Box
|
||||
key={seat.id}
|
||||
ref={(el: HTMLDivElement | null) => { 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',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.2,
|
||||
color: DS_NEUTRAL.white,
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
|
||||
}}
|
||||
>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.25,
|
||||
color: on ? DS_NEUTRAL.gold : 'rgba(255,255,255,0.82)',
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
|
||||
}}
|
||||
>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
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] && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.25,
|
||||
color: TONE_COLOR[workload[seat.id].tone],
|
||||
}}
|
||||
>
|
||||
{workload[seat.id].label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 0, top: 0, width: 1, height: 1,
|
||||
overflow: 'hidden', clip: 'rect(0 0 0 0)',
|
||||
}}
|
||||
>
|
||||
{MEETING_SEATS.map(seat => (
|
||||
<button
|
||||
key={seat.id}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); navigate(seat.path) }}
|
||||
onFocus={() => {
|
||||
hoveredRef.current = seat.id
|
||||
sceneRef.current?.setHovered(seat.id)
|
||||
setHovered(seat.id)
|
||||
draw()
|
||||
}}
|
||||
>
|
||||
{`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'absolute', right: 12, bottom: 8,
|
||||
fontSize: '0.625rem', letterSpacing: 0.4,
|
||||
color: DS_TEXT.inverted, opacity: 0.45, pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
Kleinbasel · Blick über den Rhein nach Grossbasel
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={ROOM_BACKDROP.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: ROOM_BACKDROP.focus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} />
|
||||
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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<string, string | undefined>,
|
||||
): 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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(', '),
|
||||
}
|
||||
@@ -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<string, string | undefined> = {
|
||||
bruno,
|
||||
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||
ida,
|
||||
jana,
|
||||
zeno,
|
||||
thomas,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||
<AgentStaffOverview />
|
||||
<MeetingRoom3D />
|
||||
|
||||
<PageHeader
|
||||
title={MY_PROPERTIES_LABEL}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Property On — Belegschaftsansicht der Startseite (Runde 5).
|
||||
*
|
||||
* Hier steht, was die digitalen Mitarbeitenden menschlich macht: dass sie eine
|
||||
* Reihenfolge haben, Personalien tragen, einen Dienstzustand zeigen und
|
||||
* belegen können, was sie zuletzt getan haben. Jede dieser Zusicherungen kann
|
||||
* still brechen, ohne dass die Oberfläche kaputt aussieht — eine leere
|
||||
* Personalnummer fällt niemandem auf.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAgentPresence, formatRelativeTime } from '../agentPresenceService'
|
||||
import type { AgentPresenceInput } from '../agentPresenceService'
|
||||
import { AgentStatus } from '../../domain/teamAgent'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import type { AgentWorkload } from '../../domain/agentWorkload'
|
||||
|
||||
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||
const MINUTE = 60 * 1000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
const ago = (ms: number) => 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> = {}): AgentPresenceInput {
|
||||
return { agents: [], protocol: [], workload: WORKLOAD, ...partial }
|
||||
}
|
||||
|
||||
// Nur die Felder, die der Dienst liest.
|
||||
const dossier = (id: string, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, AgentWorkload>
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<string, AgentProtocolEntry>()
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user