Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf453d2b69 | ||
|
|
e55be64aeb | ||
|
|
42a9bece36 | ||
|
|
093a958dcf | ||
|
|
435f2aa2f1 | ||
|
|
d8f8f728cc | ||
|
|
9ed21e33a7 |
No files matched your search
Generated
+65
@@ -22,6 +22,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
|
"three": "^0.185.1",
|
||||||
"zod": "^3.25.17",
|
"zod": "^3.25.17",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
@@ -516,6 +518,13 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
@@ -1935,6 +1944,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -2060,6 +2076,35 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.185.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
|
||||||
|
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.3",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||||
@@ -3219,6 +3264,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -4092,6 +4144,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -4805,6 +4864,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.185.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||||
|
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
|
"three": "^0.185.1",
|
||||||
"zod": "^3.25.17",
|
"zod": "^3.25.17",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
|
|||||||
+10
-4
@@ -8,19 +8,19 @@ import { useSessionStore } from './stores/sessionStore'
|
|||||||
import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants'
|
import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants'
|
||||||
|
|
||||||
// Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg.
|
// Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg.
|
||||||
|
// Seit Runde 5 ist das die Startseite; die frühere «Übersicht» existiert nicht mehr.
|
||||||
const WORKSPACE_HOME: Record<string, string> = {
|
const WORKSPACE_HOME: Record<string, string> = {
|
||||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
[WorkspaceType.SUPPLY]: ROUTES.SUPPLY.PROPERTIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleRedirect() {
|
function RoleRedirect() {
|
||||||
const currentUser = useSessionStore(s => s.currentUser)
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
||||||
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
return <Navigate to={WORKSPACE_HOME[first] ?? ROUTES.SUPPLY.PROPERTIES} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
||||||
|
|
||||||
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
|
||||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||||
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
@@ -54,7 +54,13 @@ function App() {
|
|||||||
|
|
||||||
{/* Supply Workspace */}
|
{/* Supply Workspace */}
|
||||||
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
||||||
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
{/* Runde 5, §2.1: Die Hauptseite «Übersicht» ist entfernt. Der
|
||||||
|
alte Pfad bleibt als Umleitung bestehen, damit Lesezeichen
|
||||||
|
und Deep Links auf der Startseite landen statt im Nichts. */}
|
||||||
|
<Route
|
||||||
|
path={ROUTES.SUPPLY.LEGACY_DASHBOARD}
|
||||||
|
element={<Navigate to={ROUTES.SUPPLY.PROPERTIES} replace />}
|
||||||
|
/>
|
||||||
<Route path="/supply/properties" element={<Properties />} />
|
<Route path="/supply/properties" element={<Properties />} />
|
||||||
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
|
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
|
||||||
<Route path="/supply/properties/:propertyId" element={<Properties />} />
|
<Route path="/supply/properties/:propertyId" element={<Properties />} />
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Bildherkunft
|
||||||
|
|
||||||
|
| Datei | Inhalt | Herkunft |
|
||||||
|
|---|---|---|
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 |
@@ -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 |
@@ -3,8 +3,7 @@ import type { AssistantContext } from '../../domain/assistant'
|
|||||||
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
|
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
|
||||||
|
|
||||||
const PAGE_LABELS: Record<string, string> = {
|
const PAGE_LABELS: Record<string, string> = {
|
||||||
'/supply/dashboard': 'Übersicht',
|
'/supply/properties': 'Startseite',
|
||||||
'/supply/properties': 'Meine Objekte',
|
|
||||||
'/supply/match-center': 'Eingehende Bedarfe',
|
'/supply/match-center': 'Eingehende Bedarfe',
|
||||||
'/supply/data-quality': 'Datenpflege',
|
'/supply/data-quality': 'Datenpflege',
|
||||||
'/supply/future-availability':'Marktchancen',
|
'/supply/future-availability':'Marktchancen',
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import { UserRole } from '../../domain/enums'
|
|
||||||
import { useSwitchDemoRole } from '../../hooks/useAuth'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
|
||||||
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung' },
|
|
||||||
{ role: UserRole.DEMAND_USER, label: 'Bürosuche' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DemoRoleSwitcher() {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
const switchDemoRole = useSwitchDemoRole()
|
|
||||||
|
|
||||||
function handleSwitch(role: UserRole) {
|
|
||||||
switchDemoRole.mutate(role)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 1, py: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}
|
|
||||||
>
|
|
||||||
Demo-Modus
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{DEMO_ROLES.map(({ role, label }) => {
|
|
||||||
const active = currentUser?.role === role
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={role}
|
|
||||||
label={label}
|
|
||||||
size="small"
|
|
||||||
clickable
|
|
||||||
onClick={() => handleSwitch(role)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: 22,
|
|
||||||
bgcolor: active ? '#152642' : 'transparent',
|
|
||||||
color: active ? '#fff' : 'text.secondary',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: active ? '#152642' : 'divider',
|
|
||||||
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,5 +3,4 @@ export { SessionExpired } from './SessionExpired'
|
|||||||
export { PermissionGate } from './PermissionGate'
|
export { PermissionGate } from './PermissionGate'
|
||||||
export { RoleGuard } from './RoleGuard'
|
export { RoleGuard } from './RoleGuard'
|
||||||
export { ProtectedRoute } from './ProtectedRoute'
|
export { ProtectedRoute } from './ProtectedRoute'
|
||||||
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
|
|
||||||
export { OrganizationSwitcher } from './OrganizationSwitcher'
|
export { OrganizationSwitcher } from './OrganizationSwitcher'
|
||||||
@@ -1,46 +1,35 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router'
|
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 { useLayoutStore } from '../../stores/layoutStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
||||||
import { RightContextPanel } from './RightContextPanel'
|
import { RightContextPanel } from './RightContextPanel'
|
||||||
import { ToastProvider } from '../ui'
|
import { ToastProvider } from '../ui'
|
||||||
import { Sidebar } from './AppShellSidebar'
|
|
||||||
import { TopBar } from './AppShellTopBar'
|
import { TopBar } from './AppShellTopBar'
|
||||||
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig'
|
import { WORKSPACE_CONFIG, getWorkspaceFromPath } from './appShellConfig'
|
||||||
import { WorkspaceType } from '../../domain/enums'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AppShell
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function AppShell() {
|
||||||
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
||||||
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
|
||||||
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||||
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
|
||||||
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
|
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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
|
// Arbeitsbereich am Pfad ausrichten
|
||||||
// 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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detected = getWorkspaceFromPath(location.pathname)
|
const detected = getWorkspaceFromPath(location.pathname)
|
||||||
if (detected && detected !== activeWorkspace) {
|
if (detected && detected !== activeWorkspace) {
|
||||||
@@ -48,8 +37,7 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
||||||
|
|
||||||
// When the user changes (role switch), redirect to their first allowed workspace
|
// Nach einem Rollenwechsel auf den ersten erlaubten Arbeitsbereich umleiten
|
||||||
// if the current workspace is no longer permitted
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return
|
if (!currentUser) return
|
||||||
const allowed = currentUser.allowedWorkspaces
|
const allowed = currentUser.allowedWorkspaces
|
||||||
@@ -64,60 +52,15 @@ export function AppShell() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentUser])
|
}, [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 (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||||
{/* Desktop: permanent sidebar */}
|
<TopBar pathname={location.pathname} />
|
||||||
{!isMobile && sidebarContent}
|
|
||||||
|
|
||||||
{/* 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' }}>
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<RightContextPanel />
|
<RightContextPanel />
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Box>
|
</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,61 +1,55 @@
|
|||||||
import { Box, IconButton } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { Menu } from 'lucide-react'
|
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
import { NotificationButton } from './NotificationButton'
|
import { NotificationButton } from './NotificationButton'
|
||||||
import { UserMenu } from './UserMenu'
|
import { GlobalNavMenu } from './GlobalNavMenu'
|
||||||
|
import { AgentChatStickyBar } from '../team'
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
pathname: string
|
pathname: string
|
||||||
onMenuClick?: () => void
|
|
||||||
isMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kopfzeile der Anwendung.
|
* Kopfzeile der Anwendung.
|
||||||
*
|
*
|
||||||
* Sie trägt nur noch, was global gilt: Mandant, Benachrichtigungen und Konto.
|
* Sie trägt seit Runde 6 die gesamte Navigation: Mandant, Benachrichtigungen
|
||||||
* Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides
|
* und — an der Stelle des früheren Profilsymbols — ein Menüsymbol, das das
|
||||||
* stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie
|
* globale Klappmenü öffnet (§11, §15). Das Profil ist vollständig in dieses
|
||||||
* geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über.
|
* Menü gewandert; in der Leiste selbst steht es nicht mehr.
|
||||||
|
*
|
||||||
|
* 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
sx={{
|
sx={{
|
||||||
height: 64,
|
height: 64,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: theme => theme.zIndex.appBar,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
gap: 1,
|
||||||
px: { xs: 1.5, sm: 3 },
|
px: { xs: 1.5, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
|
{/* Links bleibt frei — die Navigation liegt vollständig rechts. */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ flexShrink: 0 }} />
|
||||||
{isMobile && (
|
|
||||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
|
||||||
<Menu size={20} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right side */}
|
<AgentChatStickyBar />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||||
<OrganizationContextBadge />
|
<OrganizationContextBadge />
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<UserMenu />
|
<GlobalNavMenu />
|
||||||
</Box>
|
</Box>
|
||||||
</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,106 +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 { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
|
|
||||||
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>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Demo role switcher */}
|
|
||||||
<Box sx={{ px: 1, py: 1 }}>
|
|
||||||
<DemoRoleSwitcher />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<MenuItem onClick={handleLogout}>
|
|
||||||
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
|
||||||
Abmelden
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,19 +10,36 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
|
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
|
||||||
import { WorkspaceType } from '../../../domain/enums'
|
import { WorkspaceType } from '../../../domain/enums'
|
||||||
import { MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
|
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
|
||||||
import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces'
|
import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces'
|
||||||
|
|
||||||
const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
|
const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
|
||||||
|
|
||||||
|
describe('Hauptnavigation der Verwaltung', () => {
|
||||||
|
it('führt «Übersicht» nicht mehr (Runde 5, §2.1)', () => {
|
||||||
|
expect(supplyNav.map(item => item.label)).not.toContain('Übersicht')
|
||||||
|
expect(supplyNav.map(item => item.path)).not.toContain(ROUTES.SUPPLY.LEGACY_DASHBOARD)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beginnt mit der «Startseite» und führt sie nur einmal', () => {
|
||||||
|
expect(supplyNav[0]?.label).toBe(HOME_NAV_LABEL)
|
||||||
|
expect(supplyNav[0]?.path).toBe(ROUTES.SUPPLY.PROPERTIES)
|
||||||
|
expect(supplyNav.filter(item => item.path === ROUTES.SUPPLY.PROPERTIES)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startet den Arbeitsbereich auf der Startseite', () => {
|
||||||
|
expect(WORKSPACE_CONFIG[WorkspaceType.SUPPLY].firstPath).toBe(ROUTES.SUPPLY.PROPERTIES)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Navigationseintrag «Meine Agenten»', () => {
|
describe('Navigationseintrag «Meine Agenten»', () => {
|
||||||
it('steht direkt unter «Meine Objekte»', () => {
|
it('steht direkt unter der «Startseite»', () => {
|
||||||
const labels = supplyNav.map(item => item.label)
|
const labels = supplyNav.map(item => item.label)
|
||||||
const objekte = labels.indexOf('Meine Objekte')
|
const startseite = labels.indexOf(HOME_NAV_LABEL)
|
||||||
const agenten = labels.indexOf(MY_AGENTS_LABEL)
|
const agenten = labels.indexOf(MY_AGENTS_LABEL)
|
||||||
|
|
||||||
expect(objekte).toBeGreaterThanOrEqual(0)
|
expect(startseite).toBeGreaterThanOrEqual(0)
|
||||||
expect(agenten).toBe(objekte + 1)
|
expect(agenten).toBe(startseite + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('führt auf den Basispfad des Funktionsbereichs', () => {
|
it('führt auf den Basispfad des Funktionsbereichs', () => {
|
||||||
@@ -64,6 +81,7 @@ describe('Navigationseintrag «Meine Agenten»', () => {
|
|||||||
|
|
||||||
describe('Seitentitel aus dem Pfad', () => {
|
describe('Seitentitel aus dem Pfad', () => {
|
||||||
it('löst Haupt- und Agentenreiter über die Navigation auf', () => {
|
it('löst Haupt- und Agentenreiter über die Navigation auf', () => {
|
||||||
|
expect(getPageNameFromPath(ROUTES.SUPPLY.PROPERTIES)).toBe(HOME_NAV_LABEL)
|
||||||
expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe(MY_AGENTS_LABEL)
|
expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe(MY_AGENTS_LABEL)
|
||||||
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_FERDI)).toBe('Ferdi')
|
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_FERDI)).toBe('Ferdi')
|
||||||
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_BRUNO)).toBe('Bruno')
|
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_BRUNO)).toBe('Bruno')
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { WorkspaceType } from '../../domain/enums'
|
import { WorkspaceType } from '../../domain/enums'
|
||||||
import { MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Building2,
|
Building2,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
@@ -63,11 +62,13 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
label: 'Verwaltung',
|
label: 'Verwaltung',
|
||||||
abbreviation: 'VW',
|
abbreviation: 'VW',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
firstPath: '/supply/dashboard',
|
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||||
chipColor: '#152642',
|
chipColor: '#152642',
|
||||||
navItems: [
|
navItems: [
|
||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
// Runde 5, §2.1/§2.2: «Übersicht» ist entfernt, «Meine Objekte» heisst im
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
// Menü «Startseite». Die Belegschaft und die Objektverwaltung liegen auf
|
||||||
|
// derselben Seite — zwei Einträge für eine Seite wären ein toter Link.
|
||||||
|
{ path: ROUTES.SUPPLY.PROPERTIES, label: HOME_NAV_LABEL, icon: Building2 },
|
||||||
{
|
{
|
||||||
path: ROUTES.SUPPLY.TEAM,
|
path: ROUTES.SUPPLY.TEAM,
|
||||||
label: MY_AGENTS_LABEL,
|
label: MY_AGENTS_LABEL,
|
||||||
@@ -93,7 +94,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
label: 'Suche',
|
label: 'Suche',
|
||||||
abbreviation: 'SU',
|
abbreviation: 'SU',
|
||||||
icon: Search,
|
icon: Search,
|
||||||
firstPath: '/supply/dashboard',
|
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||||
chipColor: '#1a7a4a',
|
chipColor: '#1a7a4a',
|
||||||
navItems: [],
|
navItems: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ export { AppShell } from './AppShell'
|
|||||||
export { PageHeader } from './PageHeader'
|
export { PageHeader } from './PageHeader'
|
||||||
export { RightContextPanel } from './RightContextPanel'
|
export { RightContextPanel } from './RightContextPanel'
|
||||||
export { CompareTray } from './CompareTray'
|
export { CompareTray } from './CompareTray'
|
||||||
export { UserMenu } from './UserMenu'
|
export { GlobalNavMenu } from './GlobalNavMenu'
|
||||||
export { NotificationButton } from './NotificationButton'
|
export { NotificationButton } from './NotificationButton'
|
||||||
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||||
|
import { Building2, Mail, MapPin, Phone, Ruler, User } from 'lucide-react'
|
||||||
|
import type { CrmLead } from '../../domain/crmLead'
|
||||||
|
import { CRM_LEAD_STAGE_LABELS } from '../../domain/crmLead'
|
||||||
|
import { LeadChannel } from '../../domain/unifiedLead'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ObjectDeepLink } from '../team'
|
||||||
|
import { LeadChannelBadge } from './LeadChannelBadge'
|
||||||
|
import { areaFitPct } from './marketLeadHelpers'
|
||||||
|
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailansicht eines CRM-Leads (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Bewusst nüchtern: der Lead ist bereits von einem Menschen erfasst, hier
|
||||||
|
* braucht es keine Konfidenz und keine Signalherleitung — sondern
|
||||||
|
* Ansprechpartner, Stand, Zuständigkeit und passende Objekte.
|
||||||
|
*
|
||||||
|
* Die Herkunft steht oben und nicht im Kleingedruckten: welches System den
|
||||||
|
* Lead geliefert hat, entscheidet darüber, wie belastbar er ist (§12).
|
||||||
|
*/
|
||||||
|
export function CrmLeadDetail({ lead }: { lead: CrmLead }) {
|
||||||
|
const { data: properties = [] } = useProperties()
|
||||||
|
|
||||||
|
const areaEstimate = lead.areaSqmMax ?? lead.areaSqmMin
|
||||||
|
const cityKey = lead.locationHint.split(',')[0].trim().toLowerCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselbe Grosszügigkeit wie bei den KI-Signalen: gesucht wird eine Spanne,
|
||||||
|
* kein exakter Wert. Ein Objekt darf die Untergrenze um ein Drittel
|
||||||
|
* unterschreiten und die Obergrenze um die Hälfte überschreiten — darüber
|
||||||
|
* hinaus lohnt sich das Gespräch nicht mehr.
|
||||||
|
*/
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const low = (lead.areaSqmMin ?? lead.areaSqmMax ?? 0) * 0.65
|
||||||
|
const high = (lead.areaSqmMax ?? lead.areaSqmMin ?? Number.MAX_SAFE_INTEGER) * 1.5
|
||||||
|
return properties
|
||||||
|
.filter(p => {
|
||||||
|
if (p.assetType !== lead.assetType) return false
|
||||||
|
const city = (p.location?.city ?? '').toLowerCase()
|
||||||
|
if (!city || (!city.includes(cityKey) && !cityKey.includes(city))) return false
|
||||||
|
return areaEstimate ? p.areaSqm >= low && p.areaSqm <= high : true
|
||||||
|
})
|
||||||
|
.map(p => ({ p, fit: areaEstimate ? areaFitPct(p.areaSqm, areaEstimate) : null }))
|
||||||
|
.sort((a, b) => (b.fit ?? 0) - (a.fit ?? 0))
|
||||||
|
.slice(0, 4)
|
||||||
|
}, [properties, lead.assetType, lead.areaSqmMin, lead.areaSqmMax, areaEstimate, cityKey])
|
||||||
|
|
||||||
|
const areaStr = areaEstimate
|
||||||
|
? lead.areaSqmMin && lead.areaSqmMax && lead.areaSqmMin !== lead.areaSqmMax
|
||||||
|
? `${lead.areaSqmMin}–${lead.areaSqmMax} m²`
|
||||||
|
: `${areaEstimate} m²`
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<LeadChannelBadge channel={LeadChannel.CRM} />
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{lead.crmSystem} · zuletzt kontaktiert {formatDateTime(lead.lastContactAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 1 }}>
|
||||||
|
{lead.companyName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||||||
|
<Chip
|
||||||
|
label={CRM_LEAD_STAGE_LABELS[lead.stage]}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: DS_BRAND.main, color: 'white', fontWeight: 600, fontSize: '0.72rem' }}
|
||||||
|
/>
|
||||||
|
<Chip icon={<MapPin size={11} />} label={lead.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||||
|
{areaStr && <Chip icon={<Ruler size={11} />} label={areaStr} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />}
|
||||||
|
<Chip label={ASSET_TYPE_LABELS[lead.assetType] ?? lead.assetType} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
Zuständig: <strong>{lead.ownerName}</strong>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<SectionLabel>Ansprechpartner</SectionLabel>
|
||||||
|
<ContactLine icon={<User size={13} />} value={lead.contactPerson} />
|
||||||
|
{lead.contactEmail && (
|
||||||
|
<ContactLine icon={<Mail size={13} />} value={lead.contactEmail} href={`mailto:${lead.contactEmail}`} />
|
||||||
|
)}
|
||||||
|
{lead.contactPhone && (
|
||||||
|
<ContactLine icon={<Phone size={13} />} value={lead.contactPhone} href={`tel:${lead.contactPhone.replace(/\s/g, '')}`} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{lead.note && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<SectionLabel>Notiz aus dem CRM</SectionLabel>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||||
|
{lead.note}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<SectionLabel>Passende Objekte im Portfolio ({matches.length})</SectionLabel>
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
Kein Portfolioobjekt erfüllt Ort, Nutzungsart und Flächenbedarf zugleich.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
matches.map(({ p, fit }) => (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||||
|
py: 1, borderBottom: '1px solid #f1f5f9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 size={14} color={DS_BRAND.main} style={{ flexShrink: 0 }} />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<ObjectDeepLink propertyId={p.id} label={p.title} fontWeight={600} />
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
|
||||||
|
{p.location?.city} · {p.areaSqm} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{fit !== null && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}>
|
||||||
|
{fit}% Flächenpassung
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactLine({ icon, value, href }: { icon: React.ReactNode; value: string; href?: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<Box sx={{ color: DS_SLATE[500], display: 'flex' }}>{icon}</Box>
|
||||||
|
{href ? (
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={href}
|
||||||
|
sx={{ color: DS_BRAND.main, fontSize: '0.8125rem', fontWeight: 500, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: DS_SLATE[900] }}>{value}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import type { LeadChannel } from '../../domain/unifiedLead'
|
||||||
|
import { LEAD_CHANNEL_META } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennzeichen der Kanalherkunft direkt neben dem Lead (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Eine einzige Komponente für Liste und Detail: derselbe Kanal darf nicht an
|
||||||
|
* zwei Stellen zwei Farben haben. Beschriftung und Farben kommen aus
|
||||||
|
* `LEAD_CHANNEL_META` (CLAUDE.md §8.3).
|
||||||
|
*/
|
||||||
|
export const LeadChannelBadge = memo(function LeadChannelBadge({
|
||||||
|
channel,
|
||||||
|
size = 'small',
|
||||||
|
}: {
|
||||||
|
channel: LeadChannel
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}) {
|
||||||
|
const meta = LEAD_CHANNEL_META[channel]
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={meta.label}
|
||||||
|
size={size}
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: meta.color,
|
||||||
|
bgcolor: meta.bg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: meta.color,
|
||||||
|
'& .MuiChip-label': { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import type { LeadChannel, LeadChannelFilter } from '../../domain/unifiedLead'
|
||||||
|
import { LEAD_CHANNEL_ALL, LEAD_CHANNEL_ORDER } from '../../domain/unifiedLead'
|
||||||
|
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT, LEAD_CHANNEL_META } from '../../lib/ds'
|
||||||
|
|
||||||
|
export interface LeadChannelFilterBarProps {
|
||||||
|
value: LeadChannelFilter
|
||||||
|
onChange: (next: LeadChannelFilter) => void
|
||||||
|
/** Anzahl je Kanal plus Gesamtzahl — der Filter zeigt, was er zu bieten hat. */
|
||||||
|
counts: Record<LeadChannelFilter, number>
|
||||||
|
onCreateHinweis: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter «Kanaltyp» im bisherigen Kanalbalken (Runde 5, §13).
|
||||||
|
*
|
||||||
|
* Er steht genau dort, wo vorher die Reiter «KI-Signale» und «Netzwerk»
|
||||||
|
* standen. Aus dem Umschalten zwischen zwei Listen ist ein Filter über eine
|
||||||
|
* gemeinsame Liste geworden: Standard ist «Alle», und die Wahl wirkt sofort,
|
||||||
|
* ohne Seitenwechsel.
|
||||||
|
*
|
||||||
|
* Die Zahlen neben den Beschriftungen sind bewusst Teil des Filters — ohne sie
|
||||||
|
* wüsste niemand, ob ein leeres Ergebnis am Filter oder an der Datenlage liegt.
|
||||||
|
*/
|
||||||
|
export function LeadChannelFilterBar({ value, onChange, counts, onCreateHinweis }: LeadChannelFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1,
|
||||||
|
px: 3, py: 1.25,
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
Kanaltyp
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FilterChip
|
||||||
|
label="Alle"
|
||||||
|
count={counts[LEAD_CHANNEL_ALL]}
|
||||||
|
active={value === LEAD_CHANNEL_ALL}
|
||||||
|
activeColor={DS_BRAND.main}
|
||||||
|
onClick={() => onChange(LEAD_CHANNEL_ALL)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{LEAD_CHANNEL_ORDER.map((channel: LeadChannel) => (
|
||||||
|
<FilterChip
|
||||||
|
key={channel}
|
||||||
|
label={LEAD_CHANNEL_META[channel]?.label ?? channel}
|
||||||
|
count={counts[channel]}
|
||||||
|
active={value === channel}
|
||||||
|
activeColor={LEAD_CHANNEL_META[channel]?.color ?? DS_BRAND.main}
|
||||||
|
onClick={() => onChange(channel)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Netzwerk-Hinweise entstehen weiterhin hier — die Funktion war an den
|
||||||
|
entfernten Reiter gebunden und hätte sonst keinen Einstieg mehr. */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Plus size={13} />}
|
||||||
|
onClick={onCreateHinweis}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', fontSize: '0.75rem',
|
||||||
|
bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hinweis erfassen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({
|
||||||
|
label, count, active, activeColor, onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
active: boolean
|
||||||
|
activeColor: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={`${label} (${count})`}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
sx={{
|
||||||
|
height: 26,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: active ? 700 : 500,
|
||||||
|
bgcolor: active ? activeColor : DS_SLATE[100],
|
||||||
|
color: active ? DS_NEUTRAL.white : DS_SLATE[600],
|
||||||
|
'&:hover': { bgcolor: active ? activeColor : DS_SLATE[200] },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { memo } from 'react'
|
|
||||||
import { Box, Checkbox, Chip, Typography } from '@mui/material'
|
import { Box, Checkbox, Chip, Typography } from '@mui/material'
|
||||||
import { Building2, ExternalLink } from 'lucide-react'
|
import { Building2, ExternalLink } from 'lucide-react'
|
||||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import type { ExtractedContact } from '../../domain/futureSignal'
|
import type { ExtractedContact } from '../../domain/futureSignal'
|
||||||
import { ObjectDeepLink } from '../team'
|
import { ObjectDeepLink } from '../team'
|
||||||
@@ -9,7 +7,7 @@ import { CONTACT_ICONS } from './marketLeadHelpers'
|
|||||||
import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signalliste und Signaldetail der Nora-Seite.
|
* Bausteine der Signal-Detailansicht auf der Nora-Seite.
|
||||||
*
|
*
|
||||||
* Ausgezogen aus `MarketIntelligence.tsx` — die Seite hält jetzt nur noch
|
* Ausgezogen aus `MarketIntelligence.tsx` — die Seite hält jetzt nur noch
|
||||||
* Reiter, Auswahl und Rahmen.
|
* Reiter, Auswahl und Rahmen.
|
||||||
@@ -17,50 +15,6 @@ import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
|||||||
|
|
||||||
// ── Left pane list item ──────────────────────────────────────────────────────
|
// ── Left pane list item ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Kompakte Signalkarte (Runde 4, §7.2).
|
|
||||||
*
|
|
||||||
* Prozentsatz und Objektanzahl sind entfallen: beide Zahlen wurden auf der
|
|
||||||
* Karte gelesen, ohne dass man etwas mit ihnen anfangen konnte — die
|
|
||||||
* Entscheidung fällt in der Detailansicht. Übrig bleiben die Kerninformationen.
|
|
||||||
*/
|
|
||||||
const LeadListItem = memo(function LeadListItem({
|
|
||||||
lead, selected, onClick,
|
|
||||||
}: {
|
|
||||||
lead: MarketLead
|
|
||||||
selected: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
const { signal } = lead
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
px: 2, py: 1.5,
|
|
||||||
borderBottom: '1px solid #f1f5f9',
|
|
||||||
borderLeft: `3px solid ${selected ? '#152642' : 'transparent'}`,
|
|
||||||
bgcolor: selected ? '#f0f9ff' : 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3, mb: 0.25 }}>
|
|
||||||
{signal.companyName ?? signal.locationHint}
|
|
||||||
</Typography>
|
|
||||||
{signal.title && (
|
|
||||||
<Typography variant="caption" sx={{ color: DS_SLATE[500], display: 'block', mb: 0.5, lineHeight: 1.3 }}>
|
|
||||||
{signal.title}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem' }}>
|
|
||||||
{signal.locationHint} · {signal.timeHorizonMonths} Mo.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Contact row ──────────────────────────────────────────────────────────────
|
// ── Contact row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,4 +113,4 @@ function SignalPropertyRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export { LeadListItem, ContactRow, SignalPropertyRow }
|
export { ContactRow, SignalPropertyRow }
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Button, Skeleton, Typography } from '@mui/material'
|
||||||
|
import { Inbox } from 'lucide-react'
|
||||||
|
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||||
|
import { LeadChannelBadge } from './LeadChannelBadge'
|
||||||
|
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/** Spaltenraster im Stil der übrigen Agentenlisten (Livia, Bruno). */
|
||||||
|
const COLS = '120px 1.5fr 1.4fr 150px'
|
||||||
|
|
||||||
|
const COLUMN_LABELS = ['Kanal', 'Interessent', 'Anliegen', 'Eingang']
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeadRow = memo(function LeadRow({
|
||||||
|
lead, selected, onSelect,
|
||||||
|
}: {
|
||||||
|
lead: UnifiedLead
|
||||||
|
selected: boolean
|
||||||
|
onSelect: (lead: UnifiedLead) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={() => onSelect(lead)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }}
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: COLS,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 2, py: 1.5,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
bgcolor: selected ? DS_SLATE[50] : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||||
|
'&:focus-visible': { outline: '2px solid', outlineColor: DS_SLATE[400], outlineOffset: -2 },
|
||||||
|
transition: 'background-color 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<LeadChannelBadge channel={lead.channel} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||||
|
{lead.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.3 }}>
|
||||||
|
{lead.meta}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{lead.subtitle}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{formatDate(lead.receivedAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface UnifiedLeadListProps {
|
||||||
|
leads: UnifiedLead[]
|
||||||
|
isLoading: boolean
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (lead: UnifiedLead) => void
|
||||||
|
/** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */
|
||||||
|
onResetFilter?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Leadliste aller drei Kanäle (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Ersetzt die beiden getrennten Reiterlisten. Jede Zeile trägt ihr
|
||||||
|
* Kanalkennzeichen ganz links — die Herkunft ist die erste Information, die
|
||||||
|
* beim Überfliegen zählt, weil sie bestimmt, wie belastbar der Lead ist.
|
||||||
|
*/
|
||||||
|
export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onResetFilter }: UnifiedLeadListProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}>
|
||||||
|
{/* Kopfzeile */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: COLS,
|
||||||
|
gap: 1,
|
||||||
|
px: 2, py: 1,
|
||||||
|
bgcolor: DS_SLATE[50],
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COLUMN_LABELS.map(label => (
|
||||||
|
<Typography
|
||||||
|
key={label}
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading
|
||||||
|
? [1, 2, 3, 4].map(i => (
|
||||||
|
<Box key={i} sx={{ px: 2, py: 1.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<Skeleton variant="text" width="45%" />
|
||||||
|
<Skeleton variant="text" width="70%" height={14} />
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
: leads.length === 0
|
||||||
|
? (
|
||||||
|
<Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Inbox size={28} color={DS_SLATE[400]} />
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
|
||||||
|
Für diesen Kanaltyp liegen derzeit keine Leads vor.
|
||||||
|
</Typography>
|
||||||
|
{onResetFilter && (
|
||||||
|
<Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}>
|
||||||
|
Alle Kanäle anzeigen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
: leads.map(lead => (
|
||||||
|
<LeadRow
|
||||||
|
key={`${lead.channel}-${lead.id}`}
|
||||||
|
lead={lead}
|
||||||
|
selected={lead.id === selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Property On — gemeinsame Leadliste bei Nora (Runde 5, §12/§13).
|
||||||
|
*
|
||||||
|
* Zwei Zusicherungen aus dem Auftrag, die sich sonst niemand ansieht:
|
||||||
|
*
|
||||||
|
* 1. Jeder Lead trägt sein Kanalkennzeichen sichtbar in der Zeile. Die Herkunft
|
||||||
|
* darf nicht bloss aus der Farbe hervorgehen und auch nicht aus der Position
|
||||||
|
* in der Liste erratbar sein.
|
||||||
|
* 2. Ein leeres Filterergebnis erklärt sich und bietet den Weg zurück auf
|
||||||
|
* «Alle». Mit den heutigen Mockdaten ist kein Kanal leer — der Fall wäre in
|
||||||
|
* der laufenden Anwendung also gar nicht auslösbar und bliebe ungeprüft, bis
|
||||||
|
* er beim ersten echten Datensatz auftritt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { screen, cleanup, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../../../test/teamTestUtils'
|
||||||
|
import { UnifiedLeadList } from '../UnifiedLeadList'
|
||||||
|
import { LeadChannel } from '../../../domain/unifiedLead'
|
||||||
|
import type { UnifiedLead } from '../../../domain/unifiedLead'
|
||||||
|
import { LEAD_CHANNEL_META } from '../../../lib/ds'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
const KI_LEAD = {
|
||||||
|
id: 'sig-1',
|
||||||
|
channel: LeadChannel.KI_SIGNAL,
|
||||||
|
title: 'Alpbach Advisors AG',
|
||||||
|
subtitle: 'Erkanntes Nachfragesignal',
|
||||||
|
meta: 'Zürich · Horizont 6 Mo.',
|
||||||
|
receivedAt: '2026-08-02T10:00:00.000Z',
|
||||||
|
signal: { id: 'sig-1' },
|
||||||
|
matchingProperties: [],
|
||||||
|
} as unknown as UnifiedLead
|
||||||
|
|
||||||
|
const NETZ_LEAD = {
|
||||||
|
id: 'mh-1',
|
||||||
|
channel: LeadChannel.NETZWERK,
|
||||||
|
title: 'Fintech Solutions AG',
|
||||||
|
subtitle: 'Sucht Büro',
|
||||||
|
meta: 'Zürich Innenstadt · 200–400 m²',
|
||||||
|
receivedAt: '2026-07-30T10:00:00.000Z',
|
||||||
|
hinweis: { id: 'mh-1' },
|
||||||
|
} as unknown as UnifiedLead
|
||||||
|
|
||||||
|
const CRM_LEAD = {
|
||||||
|
id: 'crm-1',
|
||||||
|
channel: LeadChannel.CRM,
|
||||||
|
title: 'Rheinblick Treuhand AG',
|
||||||
|
subtitle: 'In Verhandlung — sucht Büro',
|
||||||
|
meta: 'Basel, Innenstadt · 280–420 m²',
|
||||||
|
receivedAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
crmLead: { id: 'crm-1' },
|
||||||
|
} as unknown as UnifiedLead
|
||||||
|
|
||||||
|
const ALLE = [CRM_LEAD, KI_LEAD, NETZ_LEAD]
|
||||||
|
|
||||||
|
describe('Gemeinsame Leadliste', () => {
|
||||||
|
it('zeigt zu jedem Lead die Kanalherkunft als lesbaren Text', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<UnifiedLeadList leads={ALLE} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const lead of ALLE) {
|
||||||
|
const label = LEAD_CHANNEL_META[lead.channel].label
|
||||||
|
expect(screen.getByText(lead.title)).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('führt Leads aller drei Kanäle in einer einzigen Liste', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<UnifiedLeadList leads={ALLE} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||||
|
)
|
||||||
|
const kanaele = ['KI Signal', 'Netzwerk', 'CRM'].map(l => screen.getAllByText(l).length)
|
||||||
|
expect(kanaele.every(n => n > 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet den Lead beim Klick auf die ganze Zeile', () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
renderWithProviders(
|
||||||
|
<UnifiedLeadList leads={[CRM_LEAD]} isLoading={false} selectedId={null} onSelect={onSelect} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Rheinblick Treuhand AG'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(CRM_LEAD)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erklärt ein leeres Filterergebnis und bietet die Rücksetzung auf «Alle»', () => {
|
||||||
|
const onResetFilter = vi.fn()
|
||||||
|
renderWithProviders(
|
||||||
|
<UnifiedLeadList
|
||||||
|
leads={[]}
|
||||||
|
isLoading={false}
|
||||||
|
selectedId={null}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onResetFilter={onResetFilter}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/keine Leads vor/i)).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Alle Kanäle anzeigen' }))
|
||||||
|
expect(onResetFilter).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bietet ohne aktiven Filter keine Rücksetzung an — es gäbe nichts zurückzusetzen', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<UnifiedLeadList leads={[]} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/keine Leads vor/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Alle Kanäle anzeigen' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
// Nora — KI-Signale und Leads. Barrel-Export (CLAUDE.md §13).
|
// Nora — Leads aller Kanäle. Barrel-Export (CLAUDE.md §13).
|
||||||
|
|
||||||
|
|
||||||
export { LeadListItem } from './MarketLeadPanels'
|
|
||||||
export { LeadDetail } from './LeadDetail'
|
export { LeadDetail } from './LeadDetail'
|
||||||
export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||||
|
|
||||||
|
// Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM.
|
||||||
|
export { LeadChannelBadge } from './LeadChannelBadge'
|
||||||
|
export { LeadChannelFilterBar } from './LeadChannelFilterBar'
|
||||||
|
export { UnifiedLeadList } from './UnifiedLeadList'
|
||||||
|
export { CrmLeadDetail } from './CrmLeadDetail'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react'
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material'
|
import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material'
|
||||||
import { Building2, CheckCircle2, Copy, MapPin, Ruler, Search } from 'lucide-react'
|
import { Building2, CheckCircle2, Copy, MapPin, Ruler } from 'lucide-react'
|
||||||
import type { MarktHinweis } from '../../domain/marktHinweis'
|
import type { MarktHinweis } from '../../domain/marktHinweis'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
@@ -127,76 +127,6 @@ const HinweisPropertyCard = memo(function HinweisPropertyCard({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Netzwerk: HinweisListItem ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const HinweisListItem = memo(function HinweisListItem({
|
|
||||||
hinweis, selected, onClick,
|
|
||||||
}: {
|
|
||||||
hinweis: MarktHinweis
|
|
||||||
selected: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
const displayName = !hinweis.isAnonymized && hinweis.companyName
|
|
||||||
? hinweis.companyName
|
|
||||||
: `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}`
|
|
||||||
|
|
||||||
const areaStr = useMemo(() => {
|
|
||||||
if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) {
|
|
||||||
return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²`
|
|
||||||
}
|
|
||||||
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²`
|
|
||||||
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²`
|
|
||||||
return ''
|
|
||||||
}, [hinweis.areaSqmMin, hinweis.areaSqmMax])
|
|
||||||
|
|
||||||
const visibilityBadge = useMemo(() => {
|
|
||||||
if (hinweis.visibility === 'INTERN') {
|
|
||||||
return <Chip label="Intern" size="small" sx={{ bgcolor: DS_ACCENT.indigo.bg, color: DS_ACCENT.indigo.strong, height: 18, fontSize: '0.6rem' }} />
|
|
||||||
}
|
|
||||||
if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) {
|
|
||||||
return <Chip label="Geteilt" size="small" sx={{ bgcolor: DS_ACCENT.violet.bg, color: DS_ACCENT.violet.main, height: 18, fontSize: '0.6rem' }} />
|
|
||||||
}
|
|
||||||
return <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], height: 18, fontSize: '0.6rem' }} />
|
|
||||||
}, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
px: 2, py: 1.5,
|
|
||||||
borderBottom: '1px solid #f1f5f9',
|
|
||||||
borderLeft: `3px solid ${selected ? '#152642' : 'transparent'}`,
|
|
||||||
bgcolor: selected ? '#f0f9ff' : 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.25 }}>
|
|
||||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
|
||||||
{hinweis.direction === 'SUCHE'
|
|
||||||
? <Search size={14} color="#152642" />
|
|
||||||
: <Building2 size={14} color="#7c3aed" />
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{displayName}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flexShrink: 0 }}>{visibilityBadge}</Box>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem', display: 'block', mb: 0.5, pl: 2.75 }}>
|
|
||||||
{hinweis.locationHint}{areaStr}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ pl: 2.75 }}>
|
|
||||||
{hinweis.direction === 'SUCHE'
|
|
||||||
? <Chip label="Suche" size="small" sx={{ bgcolor: DS_ACCENT.blue.borderSoft, color: DS_ACCENT.blue.main, height: 16, fontSize: '0.6rem' }} />
|
|
||||||
: <Chip label="Wird frei" size="small" sx={{ bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.bright, height: 16, fontSize: '0.6rem' }} />
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Netzwerk: HinweisDetail ──────────────────────────────────────────────────
|
// ── Netzwerk: HinweisDetail ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
|
function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
|
||||||
@@ -298,4 +228,4 @@ function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export { HinweisPropertyCard, HinweisListItem, HinweisDetail }
|
export { HinweisPropertyCard, HinweisDetail }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Netzwerk-Hinweise auf der Nora-Seite. Barrel-Export (CLAUDE.md §13).
|
// Netzwerk-Hinweise auf der Nora-Seite. Barrel-Export (CLAUDE.md §13).
|
||||||
|
|
||||||
export { HinweisListItem, HinweisDetail } from './MarktHinweisPanels'
|
export { HinweisDetail } from './MarktHinweisPanels'
|
||||||
export { HinweisErfassenDialog } from './HinweisErfassenDialog'
|
export { HinweisErfassenDialog } from './HinweisErfassenDialog'
|
||||||
export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'
|
export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,133 +9,180 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Building2,
|
|
||||||
CheckCircle2,
|
|
||||||
Zap,
|
|
||||||
Search,
|
|
||||||
Mic,
|
|
||||||
Sparkles,
|
|
||||||
Newspaper,
|
|
||||||
FileCheck,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
ClipboardList,
|
||||||
|
Filter,
|
||||||
|
Inbox,
|
||||||
|
MessageSquare,
|
||||||
|
MousePointerClick,
|
||||||
|
Radar,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { AgentAvatar } from '../team'
|
||||||
|
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||||
|
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, MY_PROPERTIES_LABEL } from '../../lib/constants'
|
||||||
|
import { DS_ACCENT, DS_BRAND, DS_TEXT, LEAD_CHANNEL_META } from '../../lib/ds'
|
||||||
|
|
||||||
function FeatureRow({
|
/**
|
||||||
icon,
|
* Produkttour (Runde 5, §8).
|
||||||
color,
|
*
|
||||||
text,
|
* Sie erklärt die Anwendung, wie sie seit dieser Runde aufgebaut ist: der
|
||||||
}: {
|
* entfernte Bereich «Übersicht» kommt nicht mehr vor, der Menüeintrag heisst
|
||||||
icon: React.ReactNode
|
* «Startseite», und «Meine Objekte» bezeichnet ausschliesslich den
|
||||||
color: string
|
* Objektbereich unterhalb der Belegschaft.
|
||||||
text: string
|
*
|
||||||
|
* Die Tour zeigt Bilder und Text in einem Dialog und markiert keine Elemente
|
||||||
|
* auf der Seite. Das ist Absicht: eine Tour, die auf DOM-Knoten zeigt, läuft
|
||||||
|
* bei jeder Umgestaltung ins Leere — genau der Fall, den §8 ausschliessen will.
|
||||||
|
* Was hier steht, kann deshalb nicht auf ein fehlendes Element zeigen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function SlideFrame({ icon, title, lead, children }: {
|
||||||
|
icon: ReactNode
|
||||||
|
title: string
|
||||||
|
lead: string
|
||||||
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex items-center gap-2 mt-2">
|
<Box className="flex flex-col items-center text-center px-2">
|
||||||
<Box sx={{ color, flexShrink: 0 }}>{icon}</Box>
|
<Box className="mb-4">{icon}</Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: children ? 3 : 0 }}>
|
||||||
|
{lead}
|
||||||
|
</Typography>
|
||||||
|
{children && <Box className="w-full text-left">{children}</Box>}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({ icon, color, text }: { icon: ReactNode; color: string; text: string }) {
|
||||||
|
return (
|
||||||
|
<Box className="flex items-start gap-2 mt-2">
|
||||||
|
<Box sx={{ color, flexShrink: 0, mt: '2px' }}>{icon}</Box>
|
||||||
<Typography variant="body2">{text}</Typography>
|
<Typography variant="body2">{text}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Slide0() {
|
function SlideStartseite() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col items-center text-center px-2">
|
<SlideFrame
|
||||||
<Box className="mb-4">
|
icon={<Building2 size={48} color={DS_BRAND.main} />}
|
||||||
<Building2 size={48} color="#152642" />
|
title={`Ihre ${HOME_NAV_LABEL}`}
|
||||||
</Box>
|
lead="Der Einstieg zeigt Ihre digitalen Mitarbeitenden im Dienst — darunter folgt Ihr Objektbestand."
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
>
|
||||||
Willkommen bei Property Match
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
Die KI-gestützte Plattform für gewerbliche Immobilien
|
|
||||||
</Typography>
|
|
||||||
<Box className="w-full text-left">
|
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={<CheckCircle2 size={18} />}
|
icon={<MousePointerClick size={18} />}
|
||||||
color="#1a7a4a"
|
color={DS_BRAND.main}
|
||||||
text="Verifizierte Portfolio-Objekte"
|
text="Jede Karte zeigt, woran der Mitarbeitende gerade sitzt. Ein Klick öffnet seinen Arbeitsbereich."
|
||||||
/>
|
/>
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={<CheckCircle2 size={18} />}
|
icon={<Building2 size={18} />}
|
||||||
color="#1a7a4a"
|
color={DS_ACCENT.success.main}
|
||||||
text="Externer Markt & Inserate"
|
text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`}
|
||||||
/>
|
/>
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={<Zap size={18} />}
|
icon={<Filter size={18} />}
|
||||||
color="#7c3aed"
|
color={DS_ACCENT.warning.main}
|
||||||
text="Future Availability Intelligence"
|
text="Über «Filter & Übersicht» schränken Sie den Bestand nach Nutzungsart, Status und Verfügbarkeit ein."
|
||||||
/>
|
/>
|
||||||
</Box>
|
</SlideFrame>
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Slide1() {
|
function SlideAgenten() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col items-center text-center px-2">
|
<SlideFrame
|
||||||
<Box className="mb-4">
|
icon={<Users size={48} color={DS_BRAND.main} />}
|
||||||
<Search size={48} color="#152642" />
|
title={MY_AGENTS_LABEL}
|
||||||
</Box>
|
lead="Fünf digitale Mitarbeitende mit festem Aufgabenprofil — jeder mit eigenem Arbeitsbereich."
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
>
|
||||||
Bedarf beschreiben — KI findet Matches
|
<Box className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{AGENT_WORKSPACES.map(agent => (
|
||||||
|
<Box key={agent.id} className="flex items-center gap-1.5">
|
||||||
|
<AgentAvatar agent={agent} size={30} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', lineHeight: 1.2, color: DS_TEXT.primary }}>
|
||||||
|
{agent.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.2, color: DS_TEXT.secondary }}>
|
||||||
Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien
|
{agent.role}
|
||||||
und findet die besten Matches aus Portfolio, Markt und Future Availability.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className="w-full text-left">
|
|
||||||
<FeatureRow
|
|
||||||
icon={<Mic size={18} />}
|
|
||||||
color="#d97706"
|
|
||||||
text="Spracheingabe auf Deutsch"
|
|
||||||
/>
|
|
||||||
<FeatureRow
|
|
||||||
icon={<Sparkles size={18} />}
|
|
||||||
color="#7c3aed"
|
|
||||||
text="KI analysiert und gewichtet automatisch"
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<ClipboardList size={18} />}
|
||||||
|
color={DS_ACCENT.violet.main}
|
||||||
|
text="Über «Meine Agenten» verwalten Sie Ihr Personal: Aufgaben, Kanäle & Systeme und den Bearbeitungsverlauf."
|
||||||
|
/>
|
||||||
|
</SlideFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Slide2() {
|
function SlideChat() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col items-center text-center px-2">
|
<SlideFrame
|
||||||
<Box className="mb-4">
|
icon={<MessageSquare size={48} color={DS_BRAND.main} />}
|
||||||
<Zap size={48} color="#7c3aed" />
|
title="Jeder Agent ist ansprechbar"
|
||||||
</Box>
|
lead="Auf jeder Agentenseite steht oben ein Eingabefeld — Sie schreiben dem Agenten wie einem Menschen."
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
>
|
||||||
Future Availability Intelligence
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
Frühzeitige Marktintelligenz zu potenziell verfügbaren Flächen — verifizierte
|
|
||||||
Vertragsenden und erkannte Marktsignale, bevor sie öffentlich werden.
|
|
||||||
</Typography>
|
|
||||||
<Box className="w-full text-left">
|
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={<Newspaper size={18} />}
|
icon={<ArrowRight size={18} />}
|
||||||
color="#d97706"
|
color={DS_ACCENT.success.main}
|
||||||
text="Presse & Stelleninserate"
|
text="Beim Scrollen rutscht der Chat kompakt in die oberste Leiste und bleibt erreichbar."
|
||||||
/>
|
/>
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={<FileCheck size={18} />}
|
icon={<MessageSquare size={18} />}
|
||||||
color="#1a7a4a"
|
color={DS_ACCENT.warning.main}
|
||||||
text="Baubewilligungen & Berichte"
|
text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter."
|
||||||
/>
|
/>
|
||||||
</Box>
|
</SlideFrame>
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLIDES = [<Slide0 />, <Slide1 />, <Slide2 />]
|
function SlideLeads() {
|
||||||
|
return (
|
||||||
|
<SlideFrame
|
||||||
|
icon={<Radar size={48} color={DS_BRAND.main} />}
|
||||||
|
title="Leads aus drei Kanälen"
|
||||||
|
lead="Nora führt Marktchancen aus allen Quellen in einer Liste zusammen — mit sichtbarer Herkunft."
|
||||||
|
>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Inbox size={18} />}
|
||||||
|
color={LEAD_CHANNEL_META.KI_SIGNAL.color}
|
||||||
|
text="KI Signal — aus Presse, Baugesuchen und Stelleninseraten erkannt."
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Inbox size={18} />}
|
||||||
|
color={LEAD_CHANNEL_META.NETZWERK.color}
|
||||||
|
text="Netzwerk — persönlich erfasste Hinweise aus Gesprächen und Anlässen."
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Inbox size={18} />}
|
||||||
|
color={LEAD_CHANNEL_META.CRM.color}
|
||||||
|
text="CRM — bestehende Interessenten aus dem angebundenen Vertriebssystem."
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Filter size={18} />}
|
||||||
|
color={DS_TEXT.secondary}
|
||||||
|
text="Der Filter «Kanaltyp» blendet einzelne Quellen aus, ohne die Liste zu wechseln."
|
||||||
|
/>
|
||||||
|
</SlideFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLIDES = [<SlideStartseite />, <SlideAgenten />, <SlideChat />, <SlideLeads />]
|
||||||
|
|
||||||
export function WelcomeDialog() {
|
export function WelcomeDialog() {
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => localStorage.getItem('property_match_welcomed') === null)
|
||||||
return localStorage.getItem('property_match_welcomed') === null
|
|
||||||
})
|
|
||||||
const [activeStep, setActiveStep] = useState(0)
|
const [activeStep, setActiveStep] = useState(0)
|
||||||
|
const lastStep = SLIDES.length - 1
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
localStorage.setItem('property_match_welcomed', '1')
|
localStorage.setItem('property_match_welcomed', '1')
|
||||||
@@ -142,23 +190,19 @@ export function WelcomeDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||||
open={open}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||||
{SLIDES[activeStep]}
|
{SLIDES[activeStep]}
|
||||||
<MobileStepper
|
<MobileStepper
|
||||||
variant="dots"
|
variant="dots"
|
||||||
steps={3}
|
steps={SLIDES.length}
|
||||||
position="static"
|
position="static"
|
||||||
activeStep={activeStep}
|
activeStep={activeStep}
|
||||||
sx={{ mt: 3, background: 'transparent' }}
|
sx={{ mt: 3, background: 'transparent' }}
|
||||||
backButton={
|
backButton={
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setActiveStep((s) => s - 1)}
|
onClick={() => setActiveStep(s => s - 1)}
|
||||||
disabled={activeStep === 0}
|
disabled={activeStep === 0}
|
||||||
startIcon={<ArrowLeft size={16} />}
|
startIcon={<ArrowLeft size={16} />}
|
||||||
>
|
>
|
||||||
@@ -166,16 +210,12 @@ export function WelcomeDialog() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
nextButton={
|
nextButton={
|
||||||
activeStep === 2 ? (
|
activeStep === lastStep ? (
|
||||||
<Button size="small" variant="contained" onClick={dismiss}>
|
<Button size="small" variant="contained" onClick={dismiss}>
|
||||||
Los geht's
|
Los geht's
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button size="small" onClick={() => setActiveStep(s => s + 1)} endIcon={<ArrowRight size={16} />}>
|
||||||
size="small"
|
|
||||||
onClick={() => setActiveStep((s) => s + 1)}
|
|
||||||
endIcon={<ArrowRight size={16} />}
|
|
||||||
>
|
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Virtueller 3D-Meetingraum der Startseite (Runde 6). Barrel-Export (CLAUDE.md §13).
|
||||||
|
|
||||||
|
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,36 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import { DS_SLATE } from '../../lib/ds'
|
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
|
||||||
orgName?: string
|
|
||||||
lastUpdated?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLastUpdated(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('de-CH', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso))
|
|
||||||
} catch {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }: DashboardHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: DS_SLATE[900] }}>
|
|
||||||
{orgName}
|
|
||||||
</Typography>
|
|
||||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
{lastUpdated && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { Box, Card, CardContent, Skeleton } from '@mui/material'
|
|
||||||
|
|
||||||
function SkeletonCard() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Skeleton variant="text" width="60%" height={20} />
|
|
||||||
<Skeleton variant="text" width="40%" height={40} sx={{ mt: 1 }} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkeletonLargeCard() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Skeleton variant="text" width="50%" height={28} sx={{ mb: 2 }} />
|
|
||||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 1, mb: 1 }} />
|
|
||||||
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<Box sx={{ pb: 2 }}>
|
|
||||||
<Skeleton variant="text" width={240} height={32} />
|
|
||||||
<Skeleton variant="text" width={360} height={20} sx={{ mt: 0.5 }} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* KPI grid skeleton */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<SkeletonCard key={i} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 2 */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
|
||||||
<SkeletonLargeCard />
|
|
||||||
<SkeletonLargeCard />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 3 */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
|
||||||
<SkeletonLargeCard />
|
|
||||||
<SkeletonLargeCard />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Quick actions skeleton */}
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Skeleton variant="text" width="30%" height={28} sx={{ mb: 2 }} />
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5 }}>
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} variant="rectangular" height={36} sx={{ borderRadius: 1 }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Box, Button, Card, CardContent, Chip, LinearProgress, Typography } from '@mui/material'
|
|
||||||
import type { DataQualitySummary } from '../../domain/dashboard'
|
|
||||||
import { dataQualityColor } from '../../lib/utils'
|
|
||||||
|
|
||||||
interface DataQualityWidgetProps {
|
|
||||||
summary: DataQualitySummary
|
|
||||||
onNavigate: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityWidget({ summary, onNavigate }: DataQualityWidgetProps) {
|
|
||||||
const color = dataQualityColor(summary.avgScore / 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
||||||
Datenqualität
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" onClick={onNavigate}>
|
|
||||||
Details
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
Ø Score
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{summary.avgScore}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={summary.avgScore}
|
|
||||||
color={color}
|
|
||||||
sx={{ borderRadius: 1, height: 8 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: summary.critical > 0 ? 'error.main' : 'text.primary' }}>
|
|
||||||
{summary.critical}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
Kritische Objekte
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
|
||||||
{summary.propertiesWithMissingCritical}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
Fehlende Pflichtfelder
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{summary.topMissingFields.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>
|
|
||||||
Häufig fehlende Felder:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{summary.topMissingFields.map(field => (
|
|
||||||
<Chip key={field} label={field} size="small" color="warning" variant="outlined" />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { Box, Card, CardContent, Divider, LinearProgress, Typography } from '@mui/material'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import type { FutureSignalSummary } from '../../domain/dashboard'
|
|
||||||
|
|
||||||
interface FutureSignalWidgetProps {
|
|
||||||
summary: FutureSignalSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatItemProps {
|
|
||||||
label: string
|
|
||||||
value: number | string
|
|
||||||
highlight?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatItem({ label, value, highlight }: StatItemProps) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Typography
|
|
||||||
variant="h5"
|
|
||||||
sx={{ fontWeight: 700, color: highlight ? 'primary.main' : 'text.primary' }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const total = summary.total || 1
|
|
||||||
const dist = summary.timeHorizonDistribution
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
||||||
Marktsignale
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
|
||||||
onClick={() => navigate('/supply/future-availability')}
|
|
||||||
>
|
|
||||||
Alle anzeigen
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2, mb: 2.5 }}>
|
|
||||||
<StatItem label="Gesamt" value={summary.total} />
|
|
||||||
<StatItem label="Hohe Konfidenz" value={summary.highConfidence} highlight />
|
|
||||||
<StatItem label="Zu prüfen" value={summary.needsReview} />
|
|
||||||
<StatItem label="Ø Zeithorizont" value={`${summary.avgTimeHorizonMonths} Mon.`} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{/* Time horizon distribution */}
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, display: 'block', mb: 1 }}>
|
|
||||||
Zeithorizont-Verteilung
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
||||||
{[
|
|
||||||
{ label: '0–6 Monate', count: dist.short },
|
|
||||||
{ label: '6–12 Monate', count: dist.medium },
|
|
||||||
{ label: '12–24 Monate', count: dist.long },
|
|
||||||
].map(({ label, count }) => (
|
|
||||||
<Box key={label}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{count}</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={Math.round((count / total) * 100)}
|
|
||||||
sx={{ height: 6, borderRadius: 3 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{summary.restricted > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block' }}>
|
|
||||||
{summary.restricted} vertrauliche Signale — nur für berechtigte Nutzer sichtbar.
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
|
||||||
Marktsignale basieren auf AI-Analyse öffentlicher und interner Daten. Es handelt
|
|
||||||
sich um probabilistische Einschätzungen, keine bestätigten Objekte.
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Box } from '@mui/material'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import type { DashboardData, KpiCardData } from '../../domain/dashboard'
|
|
||||||
import { KpiCard } from './KpiCard'
|
|
||||||
|
|
||||||
interface KpiGridProps {
|
|
||||||
data: DashboardData
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KpiGrid({ data }: KpiGridProps) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const qualityColor =
|
|
||||||
data.avgDataQuality >= 80
|
|
||||||
? '#1a7a4a'
|
|
||||||
: data.avgDataQuality >= 60
|
|
||||||
? '#d97706'
|
|
||||||
: '#c0392b'
|
|
||||||
|
|
||||||
const cards: KpiCardData[] = [
|
|
||||||
{
|
|
||||||
id: 'active-properties',
|
|
||||||
label: 'Aktive Objekte',
|
|
||||||
value: `${data.activeProperties} / ${data.totalProperties}`,
|
|
||||||
tooltip: 'Objekte mit Status "Verfügbar jetzt" oder "Verfügbar bald"',
|
|
||||||
onClick: () => navigate('/supply/properties'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'avg-quality',
|
|
||||||
label: 'Ø Datenqualität',
|
|
||||||
value: `${data.avgDataQuality}%`,
|
|
||||||
accent: qualityColor,
|
|
||||||
tooltip: 'Durchschnittlicher Datenqualitäts-Score aller Objekte',
|
|
||||||
onClick: () => navigate('/supply/data-quality'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'market-signals',
|
|
||||||
label: 'Marktsignale',
|
|
||||||
value: data.futureSignals?.total ?? '–',
|
|
||||||
tooltip: 'Erkannte Marktsignale und Future Availability Indikatoren',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'critical-gaps',
|
|
||||||
label: 'Kritische Datenlücken',
|
|
||||||
value: data.dataQuality?.critical ?? '–',
|
|
||||||
accent: (data.dataQuality?.critical ?? 0) > 0 ? '#c0392b' : undefined,
|
|
||||||
tooltip: 'Objekte mit Datenqualitäts-Score unter 50%',
|
|
||||||
onClick: () => navigate('/supply/data-quality'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cards.map(card => (
|
|
||||||
<KpiCard key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Box, Button, Card, CardContent, Typography } from '@mui/material'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
|
|
||||||
const ACTIONS = [
|
|
||||||
{ label: 'Objekte verwalten', path: '/supply/properties' },
|
|
||||||
{ label: 'Match Center', path: '/supply/match-center' },
|
|
||||||
{ label: 'Marktchancen', path: '/supply/future-availability' },
|
|
||||||
{ label: 'Datenqualität', path: '/supply/data-quality' },
|
|
||||||
{ label: 'Review Queue', path: '/ops/review-queue' },
|
|
||||||
{ label: 'Markt Intelligence', path: '/supply/market-intelligence' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function QuickActionPanel() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
|
||||||
Schnellzugriff
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ACTIONS.map(action => (
|
|
||||||
<Button
|
|
||||||
key={action.path}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => navigate(action.path)}
|
|
||||||
sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Box, Button, Card, CardContent, Chip, Typography } from '@mui/material'
|
|
||||||
import type { DashboardReviewTask } from '../../domain/dashboard'
|
|
||||||
|
|
||||||
interface ReviewTaskWidgetProps {
|
|
||||||
tasks: DashboardReviewTask[]
|
|
||||||
onNavigate: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, string> = {
|
|
||||||
HIGH: 'Hoch',
|
|
||||||
MEDIUM: 'Mittel',
|
|
||||||
LOW: 'Niedrig',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
PENDING: 'Ausstehend',
|
|
||||||
IN_REVIEW: 'In Bearbeitung',
|
|
||||||
COMPLETED: 'Abgeschlossen',
|
|
||||||
}
|
|
||||||
|
|
||||||
function priorityColor(priority: string): 'error' | 'warning' | 'default' {
|
|
||||||
if (priority === 'HIGH') return 'error'
|
|
||||||
if (priority === 'MEDIUM') return 'warning'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status: string): 'warning' | 'info' | 'success' | 'default' {
|
|
||||||
if (status === 'PENDING') return 'warning'
|
|
||||||
if (status === 'IN_REVIEW') return 'info'
|
|
||||||
if (status === 'COMPLETED') return 'success'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
|
||||||
|
|
||||||
export function ReviewTaskWidget({ tasks, onNavigate }: ReviewTaskWidgetProps) {
|
|
||||||
const sorted = [...tasks].sort(
|
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
||||||
Review Queue
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" onClick={onNavigate}>
|
|
||||||
Alle anzeigen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{sorted.length === 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 2 }}>
|
|
||||||
Keine offenen Aufgaben.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
{sorted.map(task => (
|
|
||||||
<Box
|
|
||||||
key={task.id}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
py: 0.75,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
'&:last-child': { borderBottom: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
label={PRIORITY_LABELS[task.priority] ?? task.priority}
|
|
||||||
size="small"
|
|
||||||
color={priorityColor(task.priority)}
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
|
||||||
{task.title}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={STATUS_LABELS[task.status] ?? task.status}
|
|
||||||
size="small"
|
|
||||||
color={statusColor(task.status)}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Box, Button, Card, CardContent, Typography } from '@mui/material'
|
|
||||||
import type { StrongMatchItem } from '../../domain/dashboard'
|
|
||||||
import { StrongMatchMiniCard } from './StrongMatchMiniCard'
|
|
||||||
|
|
||||||
interface StrongMatchOverviewProps {
|
|
||||||
matches: StrongMatchItem[]
|
|
||||||
onNavigate: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrongMatchOverview({ matches, onNavigate }: StrongMatchOverviewProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
||||||
Starke Matches
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" onClick={onNavigate}>
|
|
||||||
Alle anzeigen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{matches.length === 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}>
|
|
||||||
Keine starken Matches vorhanden.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
matches.slice(0, 5).map(m => (
|
|
||||||
<StrongMatchMiniCard key={m.matchId} match={m} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
|
// Die Widgets der entfernten Hauptseite «Übersicht» sind mit ihr entfallen
|
||||||
|
// (Runde 5, §2.1). Geblieben sind nur die beiden, die anderswo verwendet werden.
|
||||||
export { KpiCard } from './KpiCard'
|
export { KpiCard } from './KpiCard'
|
||||||
export { KpiGrid } from './KpiGrid'
|
|
||||||
export { StrongMatchMiniCard } from './StrongMatchMiniCard'
|
export { StrongMatchMiniCard } from './StrongMatchMiniCard'
|
||||||
export { StrongMatchOverview } from './StrongMatchOverview'
|
|
||||||
export { DataQualityWidget } from './DataQualityWidget'
|
|
||||||
export { FutureSignalWidget } from './FutureSignalWidget'
|
|
||||||
export { ReviewTaskWidget } from './ReviewTaskWidget'
|
|
||||||
export { QuickActionPanel } from './QuickActionPanel'
|
|
||||||
export { DashboardSkeleton } from './DashboardSkeleton'
|
|
||||||
export { DashboardHeader } from './DashboardHeader'
|
|
||||||
export { PropertyCard } from './PropertyCard'
|
export { PropertyCard } from './PropertyCard'
|
||||||
export type { PropertyCardProps } from './PropertyCard'
|
export type { PropertyCardProps } from './PropertyCard'
|
||||||
export { PropertyTable } from './PropertyTable'
|
export { PropertyTable } from './PropertyTable'
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
||||||
|
import { SendHorizonal } from 'lucide-react'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||||
|
import { AGENT_CHAT_PROMPT_SHORT } from '../../lib/constants'
|
||||||
|
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Chatzustand im obersten globalen Balken (Runde 5, §5).
|
||||||
|
*
|
||||||
|
* Er erscheint, sobald der ausführliche Chatkopf der Agentenseite nach oben
|
||||||
|
* weggescrollt ist, und zeigt nebeneinander Porträt, Name, Frage und
|
||||||
|
* Eingabefeld. Er liegt im Balken zwischen Seitenmenü und Mandant/Konto und
|
||||||
|
* nicht als Schwebeleiste über der Seite — so kann er Tabellen, Filter und
|
||||||
|
* Seitennavigation gar nicht erst verdecken (§10).
|
||||||
|
*
|
||||||
|
* Ohne eigenen Eingabezustand: Text, Fokus und Agent kommen aus
|
||||||
|
* `agentChatStore`, damit der Wechsel zwischen beiden Zuständen mitten im
|
||||||
|
* Tippen nichts verliert.
|
||||||
|
*/
|
||||||
|
export function AgentChatStickyBar() {
|
||||||
|
const compact = useAgentChatStore(s => s.compact)
|
||||||
|
const agent = useAgentChatStore(s => s.agent)
|
||||||
|
|
||||||
|
// Der Inhalt ist eine eigene Komponente, damit `useAgentChat` erst dann
|
||||||
|
// montiert wird, wenn der kompakte Zustand tatsächlich sichtbar ist. Läge der
|
||||||
|
// Aufruf hier, liefe sein Fokuseffekt schon beim Start der Anwendung ins Leere
|
||||||
|
// und der Fokus bliebe beim Scrollen im weggescrollten Feld hängen.
|
||||||
|
if (!agent || !compact) return null
|
||||||
|
return <StickyChat />
|
||||||
|
}
|
||||||
|
|
||||||
|
function StickyChat() {
|
||||||
|
const { agent, draft, inputRef, setDraft, send, handleKeyDown, onFocus, onBlur } = useAgentChat()
|
||||||
|
if (!agent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
{...{ [AGENT_CHAT_MARKER]: '' }}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
// Abstand zu Mandant, Benachrichtigungen und Konto auf der rechten Seite.
|
||||||
|
mr: { xs: 1, sm: 2 },
|
||||||
|
maxWidth: 720,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentAvatar agent={agent} size={34} loading="eager" />
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: DS_TEXT.primary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Bei engen Breiten verschwindet zuerst der Hilfstext, danach schrumpft das
|
||||||
|
Eingabefeld (§10). Porträt und Name bleiben in jeder Breite stehen —
|
||||||
|
ohne sie wüsste niemand mehr, mit wem er schreibt.
|
||||||
|
*/}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: DS_TEXT.secondary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: { xs: 'none', lg: 'block' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{AGENT_CHAT_PROMPT_SHORT}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
inputRef={inputRef}
|
||||||
|
placeholder={`Nachricht an ${agent.name}`}
|
||||||
|
aria-label={`Nachricht an ${agent.name}`}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: 'white',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
pr: 0.5,
|
||||||
|
'& fieldset': { borderColor: DS_BORDER.default },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={send}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
aria-label={`Nachricht an ${agent.name} senden`}
|
||||||
|
sx={{ color: DS_TEXT.primary }}
|
||||||
|
>
|
||||||
|
<SendHorizonal size={15} />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { KeyboardEvent } from 'react'
|
|
||||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||||
import { SendHorizonal } from 'lucide-react'
|
import { SendHorizonal } from 'lucide-react'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||||
|
|
||||||
export interface AgentWorkspaceHeroProps {
|
export interface AgentWorkspaceHeroProps {
|
||||||
agentId: string
|
agentId: string
|
||||||
@@ -16,7 +16,13 @@ export interface AgentWorkspaceHeroProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4).
|
* Höhe des obersten globalen Balkens. Der Chatkopf gilt bereits als verschwunden,
|
||||||
|
* sobald er darunter liegt — nicht erst, wenn er den Fensterrand verlässt.
|
||||||
|
*/
|
||||||
|
const TOP_BAR_PX = 64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4; Runde 5, §5).
|
||||||
*
|
*
|
||||||
* Bewusst grosszügig und ruhig: Porträt, Name, Funktion, Frage, Eingabefeld —
|
* Bewusst grosszügig und ruhig: Porträt, Name, Funktion, Frage, Eingabefeld —
|
||||||
* mehr nicht. Kein generischer «AI Assistent»-Button, keine Vorschlagskacheln,
|
* mehr nicht. Kein generischer «AI Assistent»-Button, keine Vorschlagskacheln,
|
||||||
@@ -26,30 +32,46 @@ export interface AgentWorkspaceHeroProps {
|
|||||||
* Es gibt genau diese eine Komponente — fünf beinahe gleiche Chatbereiche
|
* Es gibt genau diese eine Komponente — fünf beinahe gleiche Chatbereiche
|
||||||
* wären fünf Stellen, an denen dieselbe Änderung nachgezogen werden müsste.
|
* wären fünf Stellen, an denen dieselbe Änderung nachgezogen werden müsste.
|
||||||
*
|
*
|
||||||
* Das Absenden ist Frontend-Simulation: es existiert kein Chat-Backend, und
|
* Seit Runde 5 meldet sie zusätzlich ihren Sichtbarkeitszustand: scrollt sie
|
||||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
* unter den obersten Balken, übernimmt dort `AgentChatStickyBar` denselben
|
||||||
|
* Chat in kompakter Form. Text und Fokus liegen dafür in `agentChatStore` —
|
||||||
|
* die Komponente hält keinen eigenen Eingabezustand mehr.
|
||||||
*/
|
*/
|
||||||
export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: AgentWorkspaceHeroProps) {
|
export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: AgentWorkspaceHeroProps) {
|
||||||
const [message, setMessage] = useState('')
|
const compact = useAgentChatStore(s => s.compact)
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const { draft, inputRef, setDraft, send, handleKeyDown, onFocus, onBlur } = useAgentChat({ active: !compact })
|
||||||
|
const openFor = useAgentChatStore(s => s.openFor)
|
||||||
|
const close = useAgentChatStore(s => s.close)
|
||||||
|
const setCompact = useAgentChatStore(s => s.setCompact)
|
||||||
|
const sectionRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
function handleSend() {
|
// Der Agent der Seite ist die Identität des Chats — auch für den sticky Balken,
|
||||||
const text = message.trim()
|
// der ausserhalb dieser Seite im globalen Kopf steht.
|
||||||
if (!text) return
|
useEffect(() => {
|
||||||
setMessage('')
|
openFor({ id: agentId, name, role })
|
||||||
showToast(`Nachricht an ${name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, 'info')
|
return () => close()
|
||||||
}
|
}, [agentId, name, role, openFor, close])
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
/**
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
* Ein `IntersectionObserver` statt eines Scroll-Listeners: er meldet sich nur
|
||||||
e.preventDefault()
|
* beim Zustandswechsel und nicht bei jedem Scrollschritt, und er funktioniert
|
||||||
handleSend()
|
* unabhängig davon, welches Element auf der jeweiligen Agentenseite scrollt.
|
||||||
}
|
*/
|
||||||
}
|
useEffect(() => {
|
||||||
|
const el = sectionRef.current
|
||||||
|
if (!el) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setCompact(!entry.isIntersecting),
|
||||||
|
{ rootMargin: `-${TOP_BAR_PX}px 0px 0px 0px`, threshold: 0 },
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [setCompact])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
|
ref={sectionRef}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -78,23 +100,26 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
|||||||
{AGENT_CHAT_PROMPT}
|
{AGENT_CHAT_PROMPT}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
<Box {...{ [AGENT_CHAT_MARKER]: '' }} sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
value={message}
|
value={draft}
|
||||||
onChange={e => setMessage(e.target.value)}
|
onChange={e => setDraft(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
inputRef={inputRef}
|
||||||
placeholder={`Nachricht an ${name} senden`}
|
placeholder={`Nachricht an ${name} senden`}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleSend}
|
onClick={send}
|
||||||
disabled={!message.trim()}
|
disabled={!draft.trim()}
|
||||||
aria-label={`Nachricht an ${name} senden`}
|
aria-label={`Nachricht an ${name} senden`}
|
||||||
sx={{ alignSelf: 'flex-end', color: DS_TEXT.primary }}
|
sx={{ alignSelf: 'flex-end', color: DS_TEXT.primary }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ import gian from '../../assets/team/gian.jpg'
|
|||||||
import ida from '../../assets/team/ida.jpg'
|
import ida from '../../assets/team/ida.jpg'
|
||||||
import jana from '../../assets/team/jana.jpg'
|
import jana from '../../assets/team/jana.jpg'
|
||||||
import zeno from '../../assets/team/zeno.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> = {
|
export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||||
bruno,
|
bruno,
|
||||||
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
|||||||
ida,
|
ida,
|
||||||
jana,
|
jana,
|
||||||
zeno,
|
zeno,
|
||||||
|
thomas,
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,8 @@ export { ChannelsSection } from './ChannelsSection'
|
|||||||
|
|
||||||
// Gemeinsame Bausteine der fünf Agentenseiten
|
// Gemeinsame Bausteine der fünf Agentenseiten
|
||||||
export { AgentWorkspaceHero } from './AgentWorkspaceHero'
|
export { AgentWorkspaceHero } from './AgentWorkspaceHero'
|
||||||
|
export { AgentChatStickyBar } from './AgentChatStickyBar'
|
||||||
|
export { useAgentChat } from './useAgentChat'
|
||||||
export { ObjectDeepLink } from './ObjectDeepLink'
|
export { ObjectDeepLink } from './ObjectDeepLink'
|
||||||
|
|
||||||
// Personalverwaltung
|
// Personalverwaltung
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||||
|
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||||
|
* zum anderen, ist das ein Zustandswechsel und kein Verlassen des Chats — ohne
|
||||||
|
* diese Markierung löschte das `blur` des alten Feldes die Fokusnotiz, die das
|
||||||
|
* neue Feld gerade braucht.
|
||||||
|
*/
|
||||||
|
export const AGENT_CHAT_MARKER = 'data-agent-chat'
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/**
|
||||||
|
* Ob diese Darstellung gerade die sichtbare ist. Beide Zustände hängen
|
||||||
|
* gleichzeitig im Baum; nur der sichtbare darf den Fokus an sich ziehen.
|
||||||
|
*/
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Chatlogik für den ausführlichen und den kompakten Zustand
|
||||||
|
* (Runde 5, §5).
|
||||||
|
*
|
||||||
|
* Beide Darstellungen senden dieselbe Nachricht, reagieren gleich auf Enter und
|
||||||
|
* reichen den Fokus über den Zustandswechsel hinweg weiter. Läge das in beiden
|
||||||
|
* Komponenten, wäre jede Korrektur zweimal zu machen — und beim zweiten Mal
|
||||||
|
* vergessen.
|
||||||
|
*
|
||||||
|
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||||
|
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||||
|
*/
|
||||||
|
export function useAgentChat({ active = true }: Options = {}) {
|
||||||
|
const agent = useAgentChatStore(s => s.agent)
|
||||||
|
const draft = useAgentChatStore(s => s.draft)
|
||||||
|
const focused = useAgentChatStore(s => s.focused)
|
||||||
|
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||||
|
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer beim Scrollen mitten im Satz ist, tippt nach dem Zustandswechsel
|
||||||
|
* weiter — im jeweils sichtbaren Feld, mit dem Cursor am Ende des Textes.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !focused) return
|
||||||
|
const el = inputRef.current
|
||||||
|
if (!el || document.activeElement === el) return
|
||||||
|
el.focus()
|
||||||
|
const end = el.value.length
|
||||||
|
el.setSelectionRange(end, end)
|
||||||
|
}, [active, focused])
|
||||||
|
|
||||||
|
const send = useCallback(() => {
|
||||||
|
const text = draft.trim()
|
||||||
|
if (!text || !agent) return
|
||||||
|
setDraft('')
|
||||||
|
showToast(
|
||||||
|
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
||||||
|
'info',
|
||||||
|
)
|
||||||
|
}, [draft, agent, setDraft, showToast])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onBlur = useCallback(
|
||||||
|
(e: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
// Fokus wandert zum anderen Chatfeld → der Nutzer ist weiterhin im Chat.
|
||||||
|
const next = e.relatedTarget
|
||||||
|
if (next instanceof HTMLElement && next.closest(`[${AGENT_CHAT_MARKER}]`)) return
|
||||||
|
// Das Feld verschwindet gerade aus dem Baum → Folge des Zustandswechsels,
|
||||||
|
// keine Entscheidung des Nutzers.
|
||||||
|
if (!e.currentTarget.isConnected) return
|
||||||
|
setFocused(false)
|
||||||
|
},
|
||||||
|
[setFocused],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
draft,
|
||||||
|
inputRef,
|
||||||
|
setDraft,
|
||||||
|
send,
|
||||||
|
handleKeyDown,
|
||||||
|
onFocus: useCallback(() => setFocused(true), [setFocused]),
|
||||||
|
onBlur,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Property On — aktuelle Auslastung eines digitalen Mitarbeitenden.
|
||||||
|
*
|
||||||
|
* Die Belegschaftsansicht der Startseite zeigt nicht nur, *wer* für einen
|
||||||
|
* arbeitet, sondern *woran gerade*. Ohne diese Zahl wäre die Szene Dekoration;
|
||||||
|
* mit ihr ist sie eine Lageübersicht — genau der Anspruch aus CLAUDE.md §1:
|
||||||
|
* jeder Bildschirm ist um eine Entscheidung herum gebaut.
|
||||||
|
*
|
||||||
|
* Die Werte sind abgeleitet, nie gespeichert (CLAUDE.md §5.5): sie entstehen
|
||||||
|
* aus den bestehenden Beständen an Fristen, Terminen, Leads und Objekten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Dringlichkeit — steuert allein die Farbe, nie die Aussage. */
|
||||||
|
export const WorkloadTone = {
|
||||||
|
/** Nichts Auffälliges. */
|
||||||
|
CALM: 'CALM',
|
||||||
|
/** Sollte diese Woche angeschaut werden. */
|
||||||
|
ATTENTION: 'ATTENTION',
|
||||||
|
/** Überfällig oder kritisch. */
|
||||||
|
URGENT: 'URGENT',
|
||||||
|
} as const
|
||||||
|
export type WorkloadTone = typeof WorkloadTone[keyof typeof WorkloadTone]
|
||||||
|
|
||||||
|
export interface AgentWorkload {
|
||||||
|
/** Agenten-ID, identisch mit `AGENT_PHOTOS` und der Sitzordnung. */
|
||||||
|
agentId: string
|
||||||
|
/** Die Zahl selbst — für Sortierung und Tests. */
|
||||||
|
count: number
|
||||||
|
/**
|
||||||
|
* Vollständiger Satz für die Plakette, z. B. «3 Fristen diese Woche».
|
||||||
|
* Bewusst inklusive Zahl: eine nackte Ziffer neben einem Kopf sagt nichts.
|
||||||
|
*/
|
||||||
|
label: string
|
||||||
|
tone: WorkloadTone
|
||||||
|
/**
|
||||||
|
* Derselbe Sachverhalt in der ersten Person — das, was der Agent dazu sagen
|
||||||
|
* würde. Digitale Mitarbeitende sollen als Personal auftreten, nicht als
|
||||||
|
* Kennzahl: «Bei 12 Fristen ist der Termin durch» ist dieselbe Information
|
||||||
|
* wie «12 überfällig», aber es spricht jemand.
|
||||||
|
*/
|
||||||
|
voice: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { AssetType } from './enums'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead aus dem angebundenen CRM (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Dritter Kanal neben KI-Signalen und Netzwerk-Hinweisen. Fachlich der am
|
||||||
|
* besten belegte der drei: hier hat ein Mensch bereits mit dem Interessenten
|
||||||
|
* gesprochen, es gibt einen Ansprechpartner, einen Bearbeitungsstand und ein
|
||||||
|
* Datum des letzten Kontakts.
|
||||||
|
*
|
||||||
|
* Eigener Typ statt einer Erweiterung von `MarktHinweis`: ein CRM-Lead trägt
|
||||||
|
* einen Verkaufstrichter und eine Zuständigkeit, ein Netzwerk-Hinweis eine
|
||||||
|
* Sichtbarkeit und eine Quelle. Zwei Bedeutungen in einem Feld wären der
|
||||||
|
* Anfang jeder späteren Verwechslung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stand im Verkaufstrichter des CRM. */
|
||||||
|
export const CrmLeadStage = {
|
||||||
|
NEW: 'NEW',
|
||||||
|
QUALIFIED: 'QUALIFIED',
|
||||||
|
VIEWING: 'VIEWING',
|
||||||
|
NEGOTIATION: 'NEGOTIATION',
|
||||||
|
} as const
|
||||||
|
export type CrmLeadStage = typeof CrmLeadStage[keyof typeof CrmLeadStage]
|
||||||
|
|
||||||
|
export const CRM_LEAD_STAGE_LABELS: Record<CrmLeadStage, string> = {
|
||||||
|
[CrmLeadStage.NEW]: 'Neu erfasst',
|
||||||
|
[CrmLeadStage.QUALIFIED]: 'Qualifiziert',
|
||||||
|
[CrmLeadStage.VIEWING]: 'Besichtigung geplant',
|
||||||
|
[CrmLeadStage.NEGOTIATION]: 'In Verhandlung',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmLead {
|
||||||
|
id: string
|
||||||
|
companyName: string
|
||||||
|
contactPerson: string
|
||||||
|
contactEmail?: string
|
||||||
|
contactPhone?: string
|
||||||
|
locationHint: string
|
||||||
|
assetType: AssetType
|
||||||
|
areaSqmMin?: number
|
||||||
|
areaSqmMax?: number
|
||||||
|
stage: CrmLeadStage
|
||||||
|
/** Zuständiger Bewirtschafter laut CRM. */
|
||||||
|
ownerName: string
|
||||||
|
/** Name des Quellsystems — die Herkunft bleibt bis zur Anzeige nachvollziehbar. */
|
||||||
|
crmSystem: string
|
||||||
|
/** ISO-Zeitstempel des letzten dokumentierten Kontakts. */
|
||||||
|
lastContactAt: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { MarktHinweis } from './marktHinweis'
|
||||||
|
import type { CrmLead } from './crmLead'
|
||||||
|
import type { FutureSignal } from './futureSignal'
|
||||||
|
import type { Property } from './property'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanalherkunft eines Leads (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Ausdrücklich Teil des Datenmodells und nicht der Darstellung: der Kanal
|
||||||
|
* entscheidet über Badge, Filter und Detailansicht. Aus der Darstellung
|
||||||
|
* zurückgeraten — «hat eine Signal-ID, also KI» — bräche beim ersten neuen
|
||||||
|
* Kanal, und niemand fände die Stelle.
|
||||||
|
*/
|
||||||
|
export const LeadChannel = {
|
||||||
|
KI_SIGNAL: 'KI_SIGNAL',
|
||||||
|
NETZWERK: 'NETZWERK',
|
||||||
|
CRM: 'CRM',
|
||||||
|
} as const
|
||||||
|
export type LeadChannel = typeof LeadChannel[keyof typeof LeadChannel]
|
||||||
|
|
||||||
|
/** Reihenfolge im Filterbalken — verbindlich (§13). */
|
||||||
|
export const LEAD_CHANNEL_ORDER: LeadChannel[] = [
|
||||||
|
LeadChannel.KI_SIGNAL,
|
||||||
|
LeadChannel.NETZWERK,
|
||||||
|
LeadChannel.CRM,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Zusätzliche Filterstellung «Alle»; sie ist kein Kanal, sondern deren Abwesenheit. */
|
||||||
|
export const LEAD_CHANNEL_ALL = 'ALL'
|
||||||
|
export type LeadChannelFilter = LeadChannel | typeof LEAD_CHANNEL_ALL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeigefelder, die jeder Lead unabhängig vom Kanal besitzt. Ohne sie müsste
|
||||||
|
* jede Zeile der gemeinsamen Liste den Kanal abfragen, bevor sie überhaupt
|
||||||
|
* einen Titel anzeigen könnte.
|
||||||
|
*/
|
||||||
|
interface UnifiedLeadBase {
|
||||||
|
id: string
|
||||||
|
channel: LeadChannel
|
||||||
|
/** Wer — Firma oder Ort, wenn keine Firma bekannt ist. */
|
||||||
|
title: string
|
||||||
|
/** Worum es geht. */
|
||||||
|
subtitle: string
|
||||||
|
/** Ort und eine kanaltypische Zusatzangabe. */
|
||||||
|
meta: string
|
||||||
|
/** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */
|
||||||
|
receivedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnifiedLead =
|
||||||
|
| (UnifiedLeadBase & {
|
||||||
|
channel: typeof LeadChannel.KI_SIGNAL
|
||||||
|
signal: FutureSignal
|
||||||
|
matchingProperties: Property[]
|
||||||
|
})
|
||||||
|
| (UnifiedLeadBase & { channel: typeof LeadChannel.NETZWERK; hinweis: MarktHinweis })
|
||||||
|
| (UnifiedLeadBase & { channel: typeof LeadChannel.CRM; crmLead: CrmLead })
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useReminders } from './useReminders'
|
||||||
|
import { useVisitAssignments } from './useVisitAssignments'
|
||||||
|
import { useExposeLeads } from './useExposeLeads'
|
||||||
|
import { useUnifiedLeads } from './useUnifiedLeads'
|
||||||
|
import { useProperties } from './useProperties'
|
||||||
|
import { computeAgentWorkload } from '../services/agentWorkloadService'
|
||||||
|
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuelle Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
|
||||||
|
*
|
||||||
|
* Der Hook sammelt nur die Bestände ein; was daraus wird, entscheidet
|
||||||
|
* `agentWorkloadService` — Schwellen, Zeitfenster und Formulierungen sind
|
||||||
|
* fachliche Festlegungen und gehören nicht in die Oberflächenschicht
|
||||||
|
* (CLAUDE.md §4.2/§5.2).
|
||||||
|
*/
|
||||||
|
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
|
||||||
|
const { data: reminders = [], isLoading: remindersLoading } = useReminders()
|
||||||
|
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
|
||||||
|
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
|
||||||
|
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
|
||||||
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }),
|
||||||
|
[reminders, visits, exposeLeads, leads, properties],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading:
|
||||||
|
remindersLoading || visitsLoading || exposeLoading ||
|
||||||
|
leadsLoading || propertiesLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { crmLeadService } from '../services/crmLeadService'
|
||||||
|
import { STALE_CRM_LEADS } from '../lib/constants'
|
||||||
|
|
||||||
|
export function useCrmLeads() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crmLeads'],
|
||||||
|
queryFn: () => crmLeadService.getAll(),
|
||||||
|
staleTime: STALE_CRM_LEADS,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { dashboardService } from '../services/dashboardService'
|
|
||||||
import type { DashboardData } from '../domain/dashboard'
|
|
||||||
|
|
||||||
export function useSupplyDashboard() {
|
|
||||||
return useQuery<DashboardData>({
|
|
||||||
queryKey: ['supply', 'dashboard'],
|
|
||||||
queryFn: () => dashboardService.getDashboardData(),
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useMarketLeads } from './useMarketLeads'
|
||||||
|
import { useMarktHinweise } from './useMarktHinweise'
|
||||||
|
import { useCrmLeads } from './useCrmLeads'
|
||||||
|
import { LeadChannel } from '../domain/unifiedLead'
|
||||||
|
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||||
|
import type { MarktHinweis } from '../domain/marktHinweis'
|
||||||
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
|
import { CRM_LEAD_STAGE_LABELS } from '../domain/crmLead'
|
||||||
|
import { ASSET_TYPE_LABELS } from '../lib/constants'
|
||||||
|
import type { MarketLead } from './useMarketLeads'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusammengeführte Leadliste aller drei Kanäle (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Die drei Quellen bleiben getrennt erfasst — KI-Signale entstehen aus
|
||||||
|
* Marktbeobachtung, Netzwerk-Hinweise aus Gesprächen, CRM-Leads aus dem
|
||||||
|
* Vertriebssystem. Zusammengelegt werden sie erst hier, für die Anzeige, und
|
||||||
|
* jeder Eintrag behält seine Kanalherkunft im Feld `channel`.
|
||||||
|
*
|
||||||
|
* Ein gemeinsamer Provider für alle drei wäre der falsche Weg: die Quellen
|
||||||
|
* haben unterschiedliche Lebenszyklen und werden später von verschiedenen
|
||||||
|
* Systemen bedient.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function areaText(min?: number, max?: number): string | null {
|
||||||
|
if (min != null && max != null && min !== max) return `${min}–${max} m²`
|
||||||
|
if (max != null) return `${max} m²`
|
||||||
|
if (min != null) return `${min} m²`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromMarketLead(lead: MarketLead): UnifiedLead {
|
||||||
|
const { signal } = lead
|
||||||
|
return {
|
||||||
|
id: signal.id,
|
||||||
|
channel: LeadChannel.KI_SIGNAL,
|
||||||
|
title: signal.companyName ?? signal.locationHint,
|
||||||
|
subtitle: signal.title ?? 'Erkanntes Nachfragesignal',
|
||||||
|
meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`,
|
||||||
|
receivedAt: signal.updatedAt || signal.createdAt,
|
||||||
|
signal,
|
||||||
|
matchingProperties: lead.matchingProperties,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromHinweis(hinweis: MarktHinweis): UnifiedLead {
|
||||||
|
const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonymer Hinweis'
|
||||||
|
const area = areaText(hinweis.areaSqmMin, hinweis.areaSqmMax)
|
||||||
|
const assetLabel = ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType
|
||||||
|
return {
|
||||||
|
id: hinweis.id,
|
||||||
|
channel: LeadChannel.NETZWERK,
|
||||||
|
title: displayName,
|
||||||
|
subtitle: hinweis.direction === 'SUCHE' ? `Sucht ${assetLabel}` : `Fläche wird verfügbar — ${assetLabel}`,
|
||||||
|
meta: [hinweis.locationHint, area, hinweis.createdBy].filter(Boolean).join(' · '),
|
||||||
|
receivedAt: hinweis.createdAt,
|
||||||
|
hinweis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCrmLead(crmLead: CrmLead): UnifiedLead {
|
||||||
|
const area = areaText(crmLead.areaSqmMin, crmLead.areaSqmMax)
|
||||||
|
const assetLabel = ASSET_TYPE_LABELS[crmLead.assetType] ?? crmLead.assetType
|
||||||
|
return {
|
||||||
|
id: crmLead.id,
|
||||||
|
channel: LeadChannel.CRM,
|
||||||
|
title: crmLead.companyName,
|
||||||
|
subtitle: `${CRM_LEAD_STAGE_LABELS[crmLead.stage]} — sucht ${assetLabel}`,
|
||||||
|
meta: [crmLead.locationHint, area, crmLead.ownerName].filter(Boolean).join(' · '),
|
||||||
|
receivedAt: crmLead.lastContactAt,
|
||||||
|
crmLead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnifiedLeads(): { data: UnifiedLead[]; isLoading: boolean } {
|
||||||
|
const { data: marketLeads, isLoading: marketLoading } = useMarketLeads()
|
||||||
|
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
|
||||||
|
const { data: crmLeads = [], isLoading: crmLoading } = useCrmLeads()
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
...marketLeads.map(fromMarketLead),
|
||||||
|
...hinweise.map(fromHinweis),
|
||||||
|
...crmLeads.map(fromCrmLead),
|
||||||
|
].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)),
|
||||||
|
[marketLeads, hinweise, crmLeads],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { data, isLoading: marketLoading || hinweiseLoading || crmLoading }
|
||||||
|
}
|
||||||
@@ -33,3 +33,21 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
|||||||
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
||||||
return AGENT_WORKSPACES.find(a => a.id === id)
|
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,
|
||||||
|
}
|
||||||
+29
-1
@@ -61,13 +61,25 @@ export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
|||||||
export const STALE_CALENDAR = 60 * 1000
|
export const STALE_CALENDAR = 60 * 1000
|
||||||
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
||||||
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
||||||
|
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
|
||||||
|
export const STALE_CRM_LEADS = 60 * 1000
|
||||||
|
|
||||||
// Route paths — single source of truth
|
// Route paths — single source of truth
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: '/',
|
HOME: '/',
|
||||||
SUPPLY: {
|
SUPPLY: {
|
||||||
DASHBOARD: '/supply/dashboard',
|
/**
|
||||||
|
* Startseite des Arbeitsbereichs (Runde 5, §2.2). Der Pfad bleibt
|
||||||
|
* `/supply/properties`: umbenannt wurde der Menüeintrag, nicht die Route —
|
||||||
|
* jede Objektverlinkung aus den Agentenseiten zeigt weiterhin hierher.
|
||||||
|
*/
|
||||||
PROPERTIES: '/supply/properties',
|
PROPERTIES: '/supply/properties',
|
||||||
|
/**
|
||||||
|
* Alter Pfad der entfernten Hauptseite «Übersicht» (Runde 5, §2.1). Er
|
||||||
|
* existiert nur noch als Umleitung auf die Startseite, damit Lesezeichen
|
||||||
|
* und Deep Links nicht ins Leere laufen.
|
||||||
|
*/
|
||||||
|
LEGACY_DASHBOARD: '/supply/dashboard',
|
||||||
MATCH_CENTER: '/supply/match-center',
|
MATCH_CENTER: '/supply/match-center',
|
||||||
FUTURE_AVAILABILITY: '/supply/future-availability',
|
FUTURE_AVAILABILITY: '/supply/future-availability',
|
||||||
DATA_QUALITY: '/supply/data-quality',
|
DATA_QUALITY: '/supply/data-quality',
|
||||||
@@ -258,6 +270,15 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
|||||||
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
||||||
export const MY_AGENTS_LABEL = 'Meine Agenten'
|
export const MY_AGENTS_LABEL = 'Meine Agenten'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung
|
||||||
|
* darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe
|
||||||
|
* Route — getrennt gehalten, weil die Produkttour ausdrücklich unterscheiden
|
||||||
|
* muss zwischen dem Menüeintrag und dem Objektbereich auf der Seite (§8).
|
||||||
|
*/
|
||||||
|
export const HOME_NAV_LABEL = 'Startseite'
|
||||||
|
export const MY_PROPERTIES_LABEL = 'Meine Objekte'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die drei Verwaltungsbereiche auf der Hauptseite. Sie liegen im Query-String
|
* Die drei Verwaltungsbereiche auf der Hauptseite. Sie liegen im Query-String
|
||||||
* (`?section=`) und nicht im Pfad: der Reiterwechsel ist eine Ansichtswahl,
|
* (`?section=`) und nicht im Pfad: der Reiterwechsel ist eine Ansichtswahl,
|
||||||
@@ -288,6 +309,13 @@ export const AGENT_SECTION_ORDER: AgentSection[] = [
|
|||||||
/** Einheitliche Frage über dem Eingabefeld jeder Agentenseite (§4). */
|
/** Einheitliche Frage über dem Eingabefeld jeder Agentenseite (§4). */
|
||||||
export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzform für den kompakten Chat im obersten Balken (Runde 5, §9). Dort steht
|
||||||
|
* die Frage neben Porträt, Name und Eingabefeld in einer Zeile — die lange
|
||||||
|
* Fassung nähme dem Eingabefeld genau den Platz, den es braucht.
|
||||||
|
*/
|
||||||
|
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
||||||
|
|
||||||
// ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
|
// ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
|
||||||
|
|
||||||
export const EXPOSE_LEAD_TABS = {
|
export const EXPOSE_LEAD_TABS = {
|
||||||
|
|||||||
@@ -291,3 +291,20 @@ export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg
|
|||||||
MAISON_WORK: { label: 'Maison Work', color: '#2d5a8a', bg: 'rgba(45,90,138,0.10)' },
|
MAISON_WORK: { label: 'Maison Work', color: '#2d5a8a', bg: 'rgba(45,90,138,0.10)' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Kanalherkunft der Leads bei Nora (Runde 5, §12) ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge je Kanal. Nach dem Muster von `RESULT_TYPE_META`: Beschriftung und
|
||||||
|
* Farben stehen zusammen, damit derselbe Kanal in Liste, Filter und Detail nie
|
||||||
|
* unterschiedlich aussieht.
|
||||||
|
*
|
||||||
|
* Der Schlüssel ist `LeadChannel` aus `domain/unifiedLead.ts`; hier steht er
|
||||||
|
* bewusst als `string`, damit die Designschicht nicht auf das Domänenmodell
|
||||||
|
* zeigt — die Abhängigkeit läuft immer nur in die andere Richtung.
|
||||||
|
*/
|
||||||
|
export const LEAD_CHANNEL_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
KI_SIGNAL: { label: 'KI Signal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||||
|
NETZWERK: { label: 'Netzwerk', color: '#1a7a4a', bg: 'rgba(26,122,74,0.10)' },
|
||||||
|
CRM: { label: 'CRM', color: '#2d5a8a', bg: 'rgba(45,90,138,0.10)' },
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
|
import { CrmLeadStage } from '../domain/crmLead'
|
||||||
|
import { AssetType } from '../domain/enums'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed-Daten des CRM-Kanals (Runde 5, §12).
|
||||||
|
*
|
||||||
|
* Bewusst wenige, dafür vollständige Datensätze: ein CRM-Lead ohne
|
||||||
|
* Ansprechpartner und ohne letzten Kontakt wäre keiner. Orte und Flächen
|
||||||
|
* liegen im Raum Basel und Nordwestschweiz, passend zum übrigen Bestand.
|
||||||
|
*/
|
||||||
|
export const crmLeads: CrmLead[] = [
|
||||||
|
{
|
||||||
|
id: 'crm-001',
|
||||||
|
companyName: 'Rheinblick Treuhand AG',
|
||||||
|
contactPerson: 'Andrea Vogt',
|
||||||
|
contactEmail: 'a.vogt@rheinblick-treuhand.ch',
|
||||||
|
contactPhone: '+41 61 271 44 18',
|
||||||
|
locationHint: 'Basel, Innenstadt',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
areaSqmMin: 280,
|
||||||
|
areaSqmMax: 420,
|
||||||
|
stage: CrmLeadStage.NEGOTIATION,
|
||||||
|
ownerName: 'Thomas Müller',
|
||||||
|
crmSystem: 'Salesforce',
|
||||||
|
lastContactAt: '2026-08-04T09:15:00.000Z',
|
||||||
|
note: 'Mietvertrag am jetzigen Standort läuft Ende März 2027 aus. Zwei Objekte besichtigt, Zollstrasse favorisiert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm-002',
|
||||||
|
companyName: 'Nordwest Dental Gruppe',
|
||||||
|
contactPerson: 'Dr. Marc Bühler',
|
||||||
|
contactEmail: 'buehler@nw-dental.ch',
|
||||||
|
locationHint: 'Basel, Kleinbasel',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
areaSqmMin: 180,
|
||||||
|
areaSqmMax: 260,
|
||||||
|
stage: CrmLeadStage.VIEWING,
|
||||||
|
ownerName: 'Sandra Keller',
|
||||||
|
crmSystem: 'Salesforce',
|
||||||
|
lastContactAt: '2026-08-01T14:40:00.000Z',
|
||||||
|
note: 'Sucht Erdgeschossfläche mit Schaufenster für zweite Praxis. Besichtigung für KW 33 angefragt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm-003',
|
||||||
|
companyName: 'Altmann Logistik AG',
|
||||||
|
contactPerson: 'Peter Altmann',
|
||||||
|
contactPhone: '+41 61 811 09 62',
|
||||||
|
locationHint: 'Pratteln',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
areaSqmMin: 1800,
|
||||||
|
areaSqmMax: 3200,
|
||||||
|
stage: CrmLeadStage.QUALIFIED,
|
||||||
|
ownerName: 'Thomas Müller',
|
||||||
|
crmSystem: 'HubSpot',
|
||||||
|
lastContactAt: '2026-07-28T07:55:00.000Z',
|
||||||
|
note: 'Expansion aus Muttenz. Rampe und 24h-Zufahrt zwingend.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm-004',
|
||||||
|
companyName: 'Studio Helle Räume GmbH',
|
||||||
|
contactPerson: 'Livia Sommer',
|
||||||
|
contactEmail: 'kontakt@hellraeume.ch',
|
||||||
|
locationHint: 'Basel, Gundeldingen',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
areaSqmMin: 90,
|
||||||
|
areaSqmMax: 160,
|
||||||
|
stage: CrmLeadStage.NEW,
|
||||||
|
ownerName: 'Sandra Keller',
|
||||||
|
crmSystem: 'HubSpot',
|
||||||
|
lastContactAt: '2026-07-24T16:05:00.000Z',
|
||||||
|
note: 'Anfrage über Kontaktformular. Budget noch offen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm-005',
|
||||||
|
companyName: 'Birsfelden Manufaktur AG',
|
||||||
|
contactPerson: 'Jonas Wyss',
|
||||||
|
contactEmail: 'j.wyss@birsfelden-manufaktur.ch',
|
||||||
|
contactPhone: '+41 61 313 27 40',
|
||||||
|
locationHint: 'Birsfelden',
|
||||||
|
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||||
|
areaSqmMin: 640,
|
||||||
|
areaSqmMax: 900,
|
||||||
|
stage: CrmLeadStage.QUALIFIED,
|
||||||
|
ownerName: 'Thomas Müller',
|
||||||
|
crmSystem: 'Salesforce',
|
||||||
|
lastContactAt: '2026-07-19T11:20:00.000Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,196 +1,126 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { Box, Chip, Skeleton, Tab, Tabs, Typography, Button } from '@mui/material'
|
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { Plus, Sparkles, Users } from 'lucide-react'
|
|
||||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
|
||||||
import { useMarktHinweise } from '../../hooks/useMarktHinweise'
|
|
||||||
import { AgentWorkspaceHero } from '../../components/team'
|
import { AgentWorkspaceHero } from '../../components/team'
|
||||||
import { LeadDetail, LeadListItem } from '../../components/market-leads'
|
import {
|
||||||
import { HinweisDetail, HinweisErfassenDialog, HinweisListItem } from '../../components/markt-hinweise'
|
CrmLeadDetail,
|
||||||
|
LeadChannelFilterBar,
|
||||||
|
LeadDetail,
|
||||||
|
UnifiedLeadList,
|
||||||
|
} from '../../components/market-leads'
|
||||||
|
import { HinweisDetail, HinweisErfassenDialog } from '../../components/markt-hinweise'
|
||||||
|
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||||
|
import { LEAD_CHANNEL_ALL, LEAD_CHANNEL_ORDER, LeadChannel } from '../../domain/unifiedLead'
|
||||||
|
import type { LeadChannelFilter, UnifiedLead } from '../../domain/unifiedLead'
|
||||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||||
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL } from '../../lib/ds'
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
const NORA = agentWorkspaceById('nora')!
|
const NORA = agentWorkspaceById('nora')!
|
||||||
|
|
||||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Nora — Marktchancen und Leads (Runde 4, §7; Runde 5, §11–§13).
|
||||||
|
*
|
||||||
|
* Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle
|
||||||
|
* statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere
|
||||||
|
* Reiterbalken ist zum Filter «Kanaltyp» geworden.
|
||||||
|
*
|
||||||
|
* Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher
|
||||||
|
* lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten
|
||||||
|
* scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie
|
||||||
|
* in den sticky Zustand. Jetzt scrollt die Seite wie bei allen anderen Agenten
|
||||||
|
* als Ganzes, und das Detail liegt in einer Schublade statt in einer zweiten
|
||||||
|
* Spalte. Das ist keine Nora-Sonderlösung, sondern dasselbe Muster wie bei
|
||||||
|
* Sina und in «Meine Objekte».
|
||||||
|
*/
|
||||||
export default function MarketIntelligence() {
|
export default function MarketIntelligence() {
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const theme = useTheme()
|
||||||
const [chosenSignalId, setChosenSignalId] = useState<string | null>(null)
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
const [chosenHinweisId, setChosenHinweisId] = useState<string | null>(null)
|
|
||||||
|
const [channel, setChannel] = useState<LeadChannelFilter>(LEAD_CHANNEL_ALL)
|
||||||
|
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||||
const [erfassenOpen, setErfassenOpen] = useState(false)
|
const [erfassenOpen, setErfassenOpen] = useState(false)
|
||||||
const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL')
|
|
||||||
|
|
||||||
const { data: leads, isLoading: leadsLoading } = useMarketLeads()
|
const { data: leads, isLoading } = useUnifiedLeads()
|
||||||
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
|
|
||||||
|
|
||||||
const filteredHinweise = useMemo(() => {
|
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||||
if (hinweisFilter === 'ALL') return hinweise
|
const { visible, counts } = useMemo(() => {
|
||||||
return hinweise.filter(h => h.visibility === hinweisFilter)
|
const tally: Record<LeadChannelFilter, number> = {
|
||||||
}, [hinweise, hinweisFilter])
|
[LEAD_CHANNEL_ALL]: leads.length,
|
||||||
|
[LeadChannel.KI_SIGNAL]: 0,
|
||||||
|
[LeadChannel.NETZWERK]: 0,
|
||||||
|
[LeadChannel.CRM]: 0,
|
||||||
|
}
|
||||||
|
for (const lead of leads) tally[lead.channel] += 1
|
||||||
|
return {
|
||||||
|
counts: tally,
|
||||||
|
visible: channel === LEAD_CHANNEL_ALL ? leads : leads.filter(l => l.channel === channel),
|
||||||
|
}
|
||||||
|
}, [leads, channel])
|
||||||
|
|
||||||
// Ohne eigene Wahl steht der erste Eintrag offen. Abgeleitet statt in einem
|
const resetFilter = useCallback(() => setChannel(LEAD_CHANNEL_ALL), [])
|
||||||
// Effekt nachgetragen: der Effekt rief `setState` beim ersten Rendern auf und
|
const closeDetail = useCallback(() => setSelected(null), [])
|
||||||
// erzwang damit einen zweiten Durchgang, in dem die Detailspalte noch leer war.
|
|
||||||
const selectedSignalId = chosenSignalId ?? leads[0]?.signal.id ?? null
|
|
||||||
const selectedHinweisId = chosenHinweisId ?? filteredHinweise[0]?.id ?? null
|
|
||||||
|
|
||||||
const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null
|
// Der neue Hinweis soll sofort auffindbar sein — auch wenn gerade nach einem
|
||||||
const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null
|
// anderen Kanal gefiltert wird.
|
||||||
|
const handleCreated = useCallback(() => {
|
||||||
|
setErfassenOpen(false)
|
||||||
|
setChannel(LEAD_CHANNEL_ALL)
|
||||||
|
setSelected(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||||
{/* Chat-Einstieg statt Seitentitel und Beschreibung (Runde 4, §7.1).
|
{/* Chat-Einstieg statt Seitentitel und Beschreibung (Runde 4, §7.1).
|
||||||
Beispielnutzung: eigene Netzwerkleads mitteilen, aktuelle Leads für
|
Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */}
|
||||||
einen Ort erfragen. */}
|
|
||||||
<Box sx={{ flexShrink: 0 }}>
|
|
||||||
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
||||||
|
|
||||||
|
<LeadChannelFilterBar
|
||||||
|
value={channel}
|
||||||
|
onChange={setChannel}
|
||||||
|
counts={counts}
|
||||||
|
onCreateHinweis={() => setErfassenOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{visible.length === leads.length
|
||||||
|
? `${leads.length} Leads aus ${LEAD_CHANNEL_ORDER.length} Kanälen`
|
||||||
|
: `${visible.length} von ${leads.length} Leads`}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<UnifiedLeadList
|
||||||
|
leads={visible}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedId={selected?.id ?? null}
|
||||||
|
onSelect={setSelected}
|
||||||
|
onResetFilter={channel === LEAD_CHANNEL_ALL ? undefined : resetFilter}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Detailnavigation: pro Kanal die passende Ansicht, in einer Schublade. */}
|
||||||
<Tabs
|
<Drawer
|
||||||
value={activeTab}
|
anchor="right"
|
||||||
onChange={(_, v: number) => setActiveTab(v)}
|
open={!!selected}
|
||||||
sx={{
|
onClose={closeDetail}
|
||||||
borderBottom: '1px solid #e2e8f0',
|
slotProps={{
|
||||||
flexShrink: 0,
|
paper: {
|
||||||
bgcolor: 'white',
|
sx: {
|
||||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 },
|
width: isMobile ? '100vw' : { md: 520, lg: 600, xl: 660 },
|
||||||
|
boxShadow: '-4px 0 24px rgba(0,0,0,0.10)',
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab icon={<Sparkles size={14} />} iconPosition="start" label="KI-Signale" />
|
{selected?.channel === LeadChannel.KI_SIGNAL && (
|
||||||
<Tab icon={<Users size={14} />} iconPosition="start" label="Netzwerk" />
|
<LeadDetail lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }} />
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Tab 0: KI-Signale */}
|
|
||||||
{activeTab === 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
||||||
{/* Left pane */}
|
|
||||||
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography>
|
|
||||||
<Chip label={leadsLoading ? '…' : leads.length} size="small" sx={{ bgcolor: DS_BRAND.main, color: DS_NEUTRAL.white, fontSize: '0.7rem', height: 20 }} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
{leadsLoading
|
|
||||||
? [1, 2, 3, 4].map(i => (
|
|
||||||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
|
||||||
<Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} />
|
|
||||||
<Skeleton variant="text" width="80%" height={14} />
|
|
||||||
<Skeleton variant="text" width="40%" height={12} />
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
: leads.map(lead => (
|
|
||||||
<LeadListItem
|
|
||||||
key={lead.signal.id}
|
|
||||||
lead={lead}
|
|
||||||
selected={lead.signal.id === selectedSignalId}
|
|
||||||
onClick={() => setChosenSignalId(lead.signal.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right pane */}
|
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{selectedLead ? (
|
|
||||||
<LeadDetail lead={selectedLead} />
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
|
||||||
<Typography variant="body2" color="text.disabled">Signal aus der Liste auswählen</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
{selected?.channel === LeadChannel.NETZWERK && <HinweisDetail hinweis={selected.hinweis} />}
|
||||||
|
{selected?.channel === LeadChannel.CRM && <CrmLeadDetail lead={selected.crmLead} />}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
{/* Tab 1: Netzwerk */}
|
|
||||||
{activeTab === 1 && (
|
|
||||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
||||||
{/* Left pane */}
|
|
||||||
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Netzwerk-Hinweise</Typography>
|
|
||||||
<Chip
|
|
||||||
label={hinweiseLoading ? '…' : filteredHinweise.length}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: DS_ACCENT.violet.main, color: DS_NEUTRAL.white, fontSize: '0.7rem', height: 20 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<Plus size={13} />}
|
|
||||||
onClick={() => setErfassenOpen(true)}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none', fontSize: '0.72rem',
|
|
||||||
bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt },
|
|
||||||
minWidth: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Erfassen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{/* Filter chips */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
||||||
{(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => (
|
|
||||||
<Chip
|
|
||||||
key={f}
|
|
||||||
label={f === 'ALL' ? 'Alle' : f === 'INTERN' ? 'Intern' : 'Plattform'}
|
|
||||||
size="small"
|
|
||||||
onClick={() => setHinweisFilter(f)}
|
|
||||||
sx={{
|
|
||||||
height: 22, fontSize: '0.68rem', cursor: 'pointer',
|
|
||||||
bgcolor: hinweisFilter === f ? '#152642' : '#f1f5f9',
|
|
||||||
color: hinweisFilter === f ? 'white' : '#475569',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
{hinweiseLoading
|
|
||||||
? [1, 2, 3].map(i => (
|
|
||||||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
|
||||||
<Skeleton variant="text" width="60%" />
|
|
||||||
<Skeleton variant="text" width="80%" height={12} />
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
: filteredHinweise.map(h => (
|
|
||||||
<HinweisListItem
|
|
||||||
key={h.id}
|
|
||||||
hinweis={h}
|
|
||||||
selected={h.id === selectedHinweisId}
|
|
||||||
onClick={() => setChosenHinweisId(h.id)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right pane */}
|
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{selectedHinweis ? (
|
|
||||||
<HinweisDetail hinweis={selectedHinweis} />
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
|
||||||
<Typography variant="body2" color="text.disabled">Hinweis aus der Liste auswählen</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog */}
|
|
||||||
<HinweisErfassenDialog
|
<HinweisErfassenDialog
|
||||||
open={erfassenOpen}
|
open={erfassenOpen}
|
||||||
onClose={() => setErfassenOpen(false)}
|
onClose={() => setErfassenOpen(false)}
|
||||||
onCreated={(id) => {
|
onCreated={handleCreated}
|
||||||
setChosenHinweisId(id)
|
|
||||||
setErfassenOpen(false)
|
|
||||||
setActiveTab(1)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
|
|||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { PageHeader } from '../../components/layout'
|
import { PageHeader } from '../../components/layout'
|
||||||
import { ViewToggle } from '../../components/shared'
|
import { ViewToggle } from '../../components/shared'
|
||||||
|
import { MeetingRoom3D } from '../../components/staff'
|
||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||||
import type { PropertyTableFilters } from '../../components/supply'
|
import type { PropertyTableFilters } from '../../components/supply'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { ROUTES, propertyDetailRoute } from '../../lib/constants'
|
import { MY_PROPERTIES_LABEL, ROUTES, propertyDetailRoute } from '../../lib/constants'
|
||||||
import { DS_SLATE } from '../../lib/ds'
|
import { DS_SLATE } from '../../lib/ds'
|
||||||
|
|
||||||
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
|
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
|
||||||
@@ -94,9 +95,17 @@ export default function Properties() {
|
|||||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
// 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%' }}>
|
||||||
|
<MeetingRoom3D />
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Objektverwaltung"
|
title={MY_PROPERTIES_LABEL}
|
||||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||||
secondaryActions={
|
secondaryActions={
|
||||||
<ViewToggle
|
<ViewToggle
|
||||||
@@ -139,7 +148,7 @@ export default function Properties() {
|
|||||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
{view === 'grid' ? (
|
{view === 'grid' ? (
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: CARD_GRID_COLUMNS, gap: 3, p: 3 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: CARD_GRID_COLUMNS, gap: 3, p: 3 }}>
|
||||||
{filtered.map(p => (
|
{filtered.map(p => (
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
|
||||||
import { ArrowRight, Building2, TrendingUp, Zap } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
import { UserRole } from '../../domain/enums'
|
|
||||||
import { DashboardHeader, ReviewTaskWidget, DashboardSkeleton } from '../../components/supply'
|
|
||||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
|
||||||
|
|
||||||
export default function SupplyDashboard() {
|
|
||||||
const { data, isLoading, isError, refetch } = useSupplyDashboard()
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const role = currentUser?.role ?? UserRole.DEMAND_USER
|
|
||||||
const isReviewer = role === UserRole.REVIEWER
|
|
||||||
|
|
||||||
if (isLoading) return <DashboardSkeleton />
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Typography variant="h6" color="error">Dashboard konnte nicht geladen werden</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">Der Service ist vorübergehend nicht verfügbar.</Typography>
|
|
||||||
<Button variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || data.totalProperties === 0) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Fügen Sie Ihr erstes Objekt hinzu — wir matchen es sofort mit aktiven Suchanfragen.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
|
|
||||||
Erstes Objekt erfassen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const strongMatchCount = data.strongMatchCount ?? 0
|
|
||||||
const topMatches = data.strongMatches ?? []
|
|
||||||
const futureSignalCount = data.futureSignals?.total ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<DashboardHeader orgName={currentUser?.organizationName} lastUpdated={data.lastUpdated} />
|
|
||||||
|
|
||||||
{/* ── Hero: Nachfrage-Intelligence ── */}
|
|
||||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
|
||||||
|
|
||||||
{/* KPI strip */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
|
|
||||||
{/* Starke Matches */}
|
|
||||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
|
||||||
<TrendingUp size={15} color={strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted} />
|
|
||||||
<Typography variant="overline" sx={{ color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
Starke Match-Anfragen
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" sx={{ fontWeight: 800, color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.disabled, lineHeight: 1 }}>
|
|
||||||
{strongMatchCount}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
qualifizierte Interessenten ≥ 80%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Aktive Objekte */}
|
|
||||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, cursor: 'pointer', '&:hover': { bgcolor: DS_BG.subtle } }}
|
|
||||||
onClick={() => navigate('/supply/properties')}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
|
||||||
<Building2 size={15} color={DS_TEXT.muted} />
|
|
||||||
<Typography variant="overline" sx={{ color: DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
Aktive Objekte
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" sx={{ fontWeight: 800, color: DS_TEXT.primary, lineHeight: 1 }}>
|
|
||||||
{data.activeProperties}
|
|
||||||
<Typography component="span" variant="h5" sx={{ color: DS_TEXT.muted, fontWeight: 400, ml: 0.5 }}>
|
|
||||||
/ {data.totalProperties}
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>verfügbar · im Bestand</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Zukunftssignale */}
|
|
||||||
<Box sx={{ p: 2.5, bgcolor: futureSignalCount > 0 ? '#faf5ff' : 'white' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
|
||||||
<Zap size={15} color={futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted} />
|
|
||||||
<Typography variant="overline" sx={{ color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
Zukunftssignale
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" sx={{ fontWeight: 800, color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.disabled, lineHeight: 1 }}>
|
|
||||||
{futureSignalCount}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
vorqualifizierte Nachfrage-Signale
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Top matches list ── */}
|
|
||||||
{topMatches.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Box sx={{ px: 2.5, py: 1.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
|
||||||
Stärkste Interessenten
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{topMatches.slice(0, 4).map((m, i) => (
|
|
||||||
<Box
|
|
||||||
key={m.matchId}
|
|
||||||
sx={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 2,
|
|
||||||
px: 2.5, py: 1.25,
|
|
||||||
borderTop: i > 0 ? `1px solid ${DS_BORDER.muted}` : undefined,
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { bgcolor: DS_BG.subtle },
|
|
||||||
}}
|
|
||||||
onClick={() => navigate('/supply/match-center')}
|
|
||||||
>
|
|
||||||
<Box sx={{
|
|
||||||
minWidth: 44, height: 36, borderRadius: 1,
|
|
||||||
bgcolor: m.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg,
|
|
||||||
border: `1px solid ${m.matchScore >= 80 ? DS_SURFACE.success.border : DS_SURFACE.warning.border}`,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem', color: m.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning }}>
|
|
||||||
{m.matchScore}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }} noWrap>{m.propertyTitle}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>{m.topReason}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
label={m.nextBestAction}
|
|
||||||
size="small"
|
|
||||||
sx={{ fontSize: '0.65rem', height: 20, bgcolor: DS_BG.subtle, color: DS_TEXT.secondary, flexShrink: 0, maxWidth: 140 }}
|
|
||||||
/>
|
|
||||||
<ArrowRight size={14} color={DS_TEXT.disabled} style={{ flexShrink: 0 }} />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<Box sx={{ px: 2.5, py: 1.25 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
onClick={() => navigate('/supply/match-center')}
|
|
||||||
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
Alle Matches anzeigen →
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* ── Datenpflege (sekundär, collapsed) ── */}
|
|
||||||
{(data.dataQuality?.critical ?? 0) > 0 && (
|
|
||||||
<Paper sx={{ p: 2, border: `1px solid ${DS_BORDER.default}`, bgcolor: DS_BG.subtle }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
|
|
||||||
{data.dataQuality!.critical} Objekte mit Datenlücken
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Vollständige Daten verbessern die Match-Qualität erheblich.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => navigate('/supply/properties')}
|
|
||||||
sx={{ textTransform: 'none', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
Datenpflege →
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isReviewer && data.reviewTasks !== null && (
|
|
||||||
<ReviewTaskWidget
|
|
||||||
tasks={data.reviewTasks}
|
|
||||||
onNavigate={() => navigate('/ops/review-queue')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
|
|
||||||
|
export interface ICrmLeadProvider {
|
||||||
|
getAll(): Promise<CrmLead[]>
|
||||||
|
getById(id: string): Promise<CrmLead | null>
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export interface DashboardStats {
|
|
||||||
totalProperties: number
|
|
||||||
verifiedProperties: number
|
|
||||||
verifiedPortfolioProperties: number
|
|
||||||
futureSignalProperties: number
|
|
||||||
totalMatches: number
|
|
||||||
pendingReviews: number
|
|
||||||
approvedMatches: number
|
|
||||||
activeNeeds: number
|
|
||||||
totalSignals: number
|
|
||||||
verifiedSignals: number
|
|
||||||
averageMatchScore: number
|
|
||||||
highConfidenceMatches: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IDashboardProvider {
|
|
||||||
getStats(organizationId?: string): Promise<DashboardStats>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ICrmLeadProvider } from './ICrmLeadProvider'
|
||||||
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
|
import { crmLeads as seed } from '../mock-data/crmLeads'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur lesend: Leads entstehen im CRM, nicht in Property On. Ein `create` hier
|
||||||
|
* wäre eine zweite Wahrheit über denselben Interessenten.
|
||||||
|
*/
|
||||||
|
const store: CrmLead[] = [...seed]
|
||||||
|
|
||||||
|
export const MockupCrmLeadProvider: ICrmLeadProvider = {
|
||||||
|
async getAll() {
|
||||||
|
return [...store]
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string) {
|
||||||
|
return store.find(l => l.id === id) ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Auslastung der digitalen Belegschaft (Runde 5).
|
||||||
|
*
|
||||||
|
* Die Zahlen auf den Namensschildern der Startseite sind das Einzige, was dort
|
||||||
|
* eine Entscheidung auslöst. Eine falsche Zahl fällt niemandem auf — sie sieht
|
||||||
|
* genauso aus wie eine richtige. Deshalb wird hier festgehalten, was die
|
||||||
|
* fachlichen Festlegungen sind: der Stichzeitpunkt ist ein Parameter, damit
|
||||||
|
* dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeAgentWorkload } from '../agentWorkloadService'
|
||||||
|
import type { AgentWorkloadInput } from '../agentWorkloadService'
|
||||||
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
|
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||||
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function iso(offsetDays: number): string {
|
||||||
|
return new Date(NOW + offsetDays * DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: AgentWorkloadInput = {
|
||||||
|
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
|
||||||
|
return { ...EMPTY, ...partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur die Felder, die der Dienst liest — der Rest gehört nicht zur Aussage.
|
||||||
|
const reminder = (dueDays: number, status: ReminderStatus = ReminderStatus.ACTIVE) =>
|
||||||
|
({ dueDate: iso(dueDays), status }) as AgentWorkloadInput['reminders'][number]
|
||||||
|
const visit = (days: number) =>
|
||||||
|
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
|
||||||
|
const property = (score: number) =>
|
||||||
|
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
|
||||||
|
|
||||||
|
describe('Auslastung je Agent', () => {
|
||||||
|
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ reminders: [reminder(-3), reminder(-1), reminder(2)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Fristen überfällig')
|
||||||
|
expect(w.tone).toBe(WorkloadTone.URGENT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt bei Ferdi ohne Überfälliges die Fristen der laufenden Woche', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
// 2 Tage und 6 Tage zählen, 20 Tage liegt ausserhalb der Woche.
|
||||||
|
input({ reminders: [reminder(2), reminder(6), reminder(20)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Fristen diese Woche')
|
||||||
|
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Ferdi nur aktive Reminder', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ reminders: [reminder(2), reminder(3, ReminderStatus.COMPLETED), reminder(4, ReminderStatus.DISMISSED)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Bruno nur Termine der kommenden sieben Tage', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ visits: [visit(-1), visit(1), visit(6), visit(9)] }),
|
||||||
|
NOW,
|
||||||
|
).bruno
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Besichtigungen diese Woche')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Sina nur Objekte unter der Datenqualitätsschwelle', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ properties: [property(0.2), property(0.55), property(0.6), property(0.9)] }),
|
||||||
|
NOW,
|
||||||
|
).sina
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Objekte mit Datenlücken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält Noras Zahl nie für dringend — sie schlägt vor, sie entscheidet nicht', () => {
|
||||||
|
const leads = Array.from({ length: 40 }, () => ({ receivedAt: iso(-1) }))
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ leads: leads as AgentWorkloadInput['leads'] }),
|
||||||
|
NOW,
|
||||||
|
).nora
|
||||||
|
|
||||||
|
expect(w.count).toBe(40)
|
||||||
|
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formuliert die Einzahl korrekt', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ exposeLeads: [{ status: ExposeLeadStatus.ACTIVE }] as AgentWorkloadInput['exposeLeads'] }),
|
||||||
|
NOW,
|
||||||
|
).livia
|
||||||
|
|
||||||
|
expect(w.label).toBe('1 Exposé offen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
|
||||||
|
const all = computeAgentWorkload(EMPTY, NOW)
|
||||||
|
|
||||||
|
expect(Object.keys(all)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
|
||||||
|
for (const w of Object.values(all)) {
|
||||||
|
expect(w.count).toBe(0)
|
||||||
|
expect(w.tone).toBe(WorkloadTone.CALM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert zum selben Stichzeitpunkt immer dasselbe Ergebnis', () => {
|
||||||
|
const data = input({ reminders: [reminder(2)], visits: [visit(1)] })
|
||||||
|
expect(computeAgentWorkload(data, NOW)).toEqual(computeAgentWorkload(data, NOW))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { ReminderStatus } from '../domain/reminder'
|
||||||
|
import type { Reminder } from '../domain/reminder'
|
||||||
|
import { ExposeLeadStatus } from '../domain/exposeLead'
|
||||||
|
import type { ExposeLead } from '../domain/exposeLead'
|
||||||
|
import type { VisitAssignment } from '../domain/visitAssignment'
|
||||||
|
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||||
|
import type { Property } from '../domain/property'
|
||||||
|
import { WorkloadTone } from '../domain/agentWorkload'
|
||||||
|
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||||
|
import { DQ_MEDIUM } from '../lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
|
||||||
|
*
|
||||||
|
* Reine Geschäftslogik, bewusst ausserhalb von React: was «diese Woche»
|
||||||
|
* bedeutet, ab wann eine Zahl dringend ist und welcher Bestand zu welchem
|
||||||
|
* Agenten gehört, sind fachliche Festlegungen und keine Darstellungsfragen
|
||||||
|
* (CLAUDE.md §4.2). Hier lässt sich das auch ohne Renderer prüfen — der
|
||||||
|
* Stichzeitpunkt ist ein Parameter, nicht die Systemuhr.
|
||||||
|
*
|
||||||
|
* Jede Zahl stammt aus dem Bestand, für den der jeweilige Agent zuständig ist.
|
||||||
|
* Es gibt keine zweite Kennzahlentabelle, die auseinanderlaufen könnte: wer
|
||||||
|
* Ferdis Zahl prüfen will, zählt seine Reminderliste nach.
|
||||||
|
*
|
||||||
|
* Bewusst genau eine Zahl je Agent — eine Plakette über einem Kopf trägt einen
|
||||||
|
* Satz, kein Dashboard. Gezeigt wird jeweils das, was zuerst eine Entscheidung
|
||||||
|
* verlangt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Ab dieser Anzahl wechselt eine Zahl von «in Arbeit» auf «dringend». */
|
||||||
|
const URGENT_FROM = {
|
||||||
|
reminders: 5,
|
||||||
|
visits: 4,
|
||||||
|
expose: 5,
|
||||||
|
dataGaps: 10,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Zeitfenster der Auswertung. */
|
||||||
|
const WINDOW_DAYS = {
|
||||||
|
/** «diese Woche» für Fristen und Termine. */
|
||||||
|
ahead: 7,
|
||||||
|
/** «neu» für Marktchancen. */
|
||||||
|
leadsBack: 14,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export interface AgentWorkloadInput {
|
||||||
|
reminders: Reminder[]
|
||||||
|
visits: VisitAssignment[]
|
||||||
|
exposeLeads: ExposeLead[]
|
||||||
|
leads: UnifiedLead[]
|
||||||
|
properties: Property[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function within(iso: string, from: number, to: number): boolean {
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
return Number.isFinite(t) && t >= from && t <= to
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(count: number, urgentFrom: number): WorkloadTone {
|
||||||
|
if (count === 0) return WorkloadTone.CALM
|
||||||
|
return count >= urgentFrom ? WorkloadTone.URGENT : WorkloadTone.ATTENTION
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(count: number, one: string, many: string): string {
|
||||||
|
return count === 1 ? one : many
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param now Stichzeitpunkt in Millisekunden. Als Parameter und nicht aus der
|
||||||
|
* Systemuhr gelesen, damit dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||||
|
*/
|
||||||
|
export function computeAgentWorkload(
|
||||||
|
input: AgentWorkloadInput,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): Record<string, AgentWorkload> {
|
||||||
|
const weekAhead = now + WINDOW_DAYS.ahead * DAY_MS
|
||||||
|
const leadsFrom = now - WINDOW_DAYS.leadsBack * DAY_MS
|
||||||
|
|
||||||
|
// ── Ferdi: Fristen ──────────────────────────────────────────────────────────
|
||||||
|
// Überfälliges zuerst: eine abgelaufene Frist wiegt schwerer als zehn
|
||||||
|
// kommende, und beide Zahlen nebeneinander wären keine Aussage mehr.
|
||||||
|
const activeReminders = input.reminders.filter(r => r.status === ReminderStatus.ACTIVE)
|
||||||
|
const overdue = activeReminders.filter(r => new Date(r.dueDate).getTime() < now).length
|
||||||
|
const dueThisWeek = activeReminders.filter(r => within(r.dueDate, now, weekAhead)).length
|
||||||
|
const ferdi: AgentWorkload = overdue > 0
|
||||||
|
? {
|
||||||
|
agentId: 'ferdi',
|
||||||
|
count: overdue,
|
||||||
|
label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`,
|
||||||
|
tone: WorkloadTone.URGENT,
|
||||||
|
voice: `Bei ${overdue} ${plural(overdue, 'Frist', 'Fristen')} ist der Termin durch. Fürs Eskalieren brauche ich deine Freigabe.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
agentId: 'ferdi',
|
||||||
|
count: dueThisWeek,
|
||||||
|
label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`,
|
||||||
|
tone: tone(dueThisWeek, URGENT_FROM.reminders),
|
||||||
|
voice: dueThisWeek === 0
|
||||||
|
? 'Diese Woche läuft keine Frist ab. Ich behalte den Bestand im Auge.'
|
||||||
|
: `${dueThisWeek} ${plural(dueThisWeek, 'Frist läuft', 'Fristen laufen')} diese Woche ab. Ich habe alle im Blick.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bruno: Besichtigungen ───────────────────────────────────────────────────
|
||||||
|
const upcomingVisits = input.visits.filter(v => within(v.scheduledAt, now, weekAhead)).length
|
||||||
|
const bruno: AgentWorkload = {
|
||||||
|
agentId: 'bruno',
|
||||||
|
count: upcomingVisits,
|
||||||
|
label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`,
|
||||||
|
tone: tone(upcomingVisits, URGENT_FROM.visits),
|
||||||
|
voice: upcomingVisits === 0
|
||||||
|
? 'Diese Woche steht kein Termin an. Der Kalender bleibt bei mir.'
|
||||||
|
: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung steht', 'Besichtigungen stehen')} an. Die Briefings sind vorbereitet.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Livia: weitergeleitete Leads ────────────────────────────────────────────
|
||||||
|
const openExpose = input.exposeLeads.filter(l => l.status === ExposeLeadStatus.ACTIVE).length
|
||||||
|
const livia: AgentWorkload = {
|
||||||
|
agentId: 'livia',
|
||||||
|
count: openExpose,
|
||||||
|
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`,
|
||||||
|
tone: tone(openExpose, URGENT_FROM.expose),
|
||||||
|
voice: openExpose === 0
|
||||||
|
? 'Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.'
|
||||||
|
: `${openExpose} ${plural(openExpose, 'Lead wartet', 'Leads warten')} auf ein Exposé von mir.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nora: Marktchancen ──────────────────────────────────────────────────────
|
||||||
|
const freshLeads = input.leads.filter(l => within(l.receivedAt, leadsFrom, now)).length
|
||||||
|
const nora: AgentWorkload = {
|
||||||
|
agentId: 'nora',
|
||||||
|
count: freshLeads,
|
||||||
|
label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`,
|
||||||
|
// Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor.
|
||||||
|
tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM,
|
||||||
|
voice: freshLeads === 0
|
||||||
|
? 'Zurzeit keine neue Chance. Ich beobachte den Markt weiter.'
|
||||||
|
: `Ich habe ${freshLeads} neue ${plural(freshLeads, 'Chance', 'Chancen')} gefunden. Entscheiden musst du.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sina: Datenpflege ───────────────────────────────────────────────────────
|
||||||
|
const gaps = input.properties.filter(p => p.dataQuality.score < DQ_MEDIUM).length
|
||||||
|
const sina: AgentWorkload = {
|
||||||
|
agentId: 'sina',
|
||||||
|
count: gaps,
|
||||||
|
label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`,
|
||||||
|
tone: tone(gaps, URGENT_FROM.dataGaps),
|
||||||
|
voice: gaps === 0
|
||||||
|
? 'Alle Objekte sind vollständig erfasst. Ich prüfe weiter.'
|
||||||
|
: `Bei ${gaps} ${plural(gaps, 'Objekt fehlen', 'Objekten fehlen')} mir Angaben. Ich melde, was ich nicht selbst finde.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
[ferdi, bruno, livia, nora, sina].map(w => [w.agentId, w]),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ function pageType(route: string): string {
|
|||||||
if (route.includes('/supply/match-center')) return 'match-center'
|
if (route.includes('/supply/match-center')) return 'match-center'
|
||||||
if (route.includes('/supply/data-quality')) return 'data-quality'
|
if (route.includes('/supply/data-quality')) return 'data-quality'
|
||||||
if (route.includes('/supply/future-availability')) return 'future-availability'
|
if (route.includes('/supply/future-availability')) return 'future-availability'
|
||||||
if (route.includes('/supply/dashboard')) return 'supply-dashboard'
|
|
||||||
if (route.includes('/demand/results')) return 'demand-results'
|
if (route.includes('/demand/results')) return 'demand-results'
|
||||||
if (route.includes('/demand/compare')) return 'compare'
|
if (route.includes('/demand/compare')) return 'compare'
|
||||||
if (route.includes('/demand/ai-search')) return 'ai-search'
|
if (route.includes('/demand/ai-search')) return 'ai-search'
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { MockupCrmLeadProvider } from '../provider/MockupCrmLeadProvider'
|
||||||
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
|
|
||||||
|
const provider = MockupCrmLeadProvider
|
||||||
|
|
||||||
|
export const crmLeadService = {
|
||||||
|
getAll: (): Promise<CrmLead[]> => provider.getAll(),
|
||||||
|
getById: (id: string): Promise<CrmLead | null> => provider.getById(id),
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { propertyService } from './propertyService'
|
|
||||||
import { matchService } from './matchService'
|
|
||||||
import { futureSignalService } from './futureSignalService'
|
|
||||||
import { dataQualityService } from './dataQualityService'
|
|
||||||
import { reviewService } from './reviewService'
|
|
||||||
import type { DashboardData } from '../domain/dashboard'
|
|
||||||
|
|
||||||
export const dashboardService = {
|
|
||||||
async getDashboardData(): Promise<DashboardData> {
|
|
||||||
const [propRes, matchRes, signalRes, qualityRes, reviewRes] = await Promise.allSettled([
|
|
||||||
propertyService.getDashboardPropertiesSummary(),
|
|
||||||
matchService.getStrongMatches(),
|
|
||||||
futureSignalService.getSignalSummary(),
|
|
||||||
dataQualityService.getPortfolioQualitySummary(),
|
|
||||||
reviewService.getDashboardTasks(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const propSummary = propRes.status === 'fulfilled' ? propRes.value : null
|
|
||||||
const strongMatches = matchRes.status === 'fulfilled' ? matchRes.value : null
|
|
||||||
const signals = signalRes.status === 'fulfilled' ? signalRes.value : null
|
|
||||||
const quality = qualityRes.status === 'fulfilled' ? qualityRes.value : null
|
|
||||||
const tasks = reviewRes.status === 'fulfilled' ? reviewRes.value : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalProperties: propSummary?.total ?? 0,
|
|
||||||
activeProperties: propSummary?.active ?? 0,
|
|
||||||
strongMatchCount: strongMatches?.length ?? 0,
|
|
||||||
avgDataQuality: quality?.avgScore ?? 0,
|
|
||||||
futureSignals: signals,
|
|
||||||
dataQuality: quality,
|
|
||||||
reviewTasks: tasks,
|
|
||||||
strongMatches,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface AgentChatSubject {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentChatState {
|
||||||
|
/** Agent der aktuell geöffneten Seite; `null` ausserhalb der Agentenseiten. */
|
||||||
|
agent: AgentChatSubject | null
|
||||||
|
/** Ungesendeter Eingabetext — geteilt zwischen ausführlichem und sticky Zustand. */
|
||||||
|
draft: string
|
||||||
|
/** true, sobald der ausführliche Chatkopf aus dem sichtbaren Bereich gescrollt ist. */
|
||||||
|
compact: boolean
|
||||||
|
/**
|
||||||
|
* Ob das Eingabefeld den Fokus hatte. Der Zustandswechsel hängt zwei
|
||||||
|
* verschiedene Eingabefelder in den Baum; ohne diese Notiz verlöre man beim
|
||||||
|
* Scrollen mitten im Tippen den Fokus (§9).
|
||||||
|
*/
|
||||||
|
focused: boolean
|
||||||
|
|
||||||
|
openFor: (agent: AgentChatSubject) => void
|
||||||
|
close: () => void
|
||||||
|
setDraft: (draft: string) => void
|
||||||
|
setCompact: (compact: boolean) => void
|
||||||
|
setFocused: (focused: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsamer Zustand des Agentenchats (Runde 5, §5).
|
||||||
|
*
|
||||||
|
* Der Chat erscheint an zwei Stellen — ausführlich auf der Seite und kompakt
|
||||||
|
* im obersten Balken —, ist aber ein einziger Chat. Eingabetext, Fokus und
|
||||||
|
* Agentenidentität liegen deshalb hier und nicht in den Komponenten: zwei
|
||||||
|
* lokale `useState` wären zwei Chats, und beim Scrollen wäre der halbe Satz
|
||||||
|
* verschwunden (§9 «Keine doppelte Chat-Instanz mit getrenntem Zustand»).
|
||||||
|
*
|
||||||
|
* Der Entwurf gehört bewusst nicht in React Query — er ist Oberflächenzustand,
|
||||||
|
* keine Serverdatei (STATE_MANAGEMENT.md).
|
||||||
|
*/
|
||||||
|
export const useAgentChatStore = create<AgentChatState>((set) => ({
|
||||||
|
agent: null,
|
||||||
|
draft: '',
|
||||||
|
compact: false,
|
||||||
|
focused: false,
|
||||||
|
|
||||||
|
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in
|
||||||
|
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen.
|
||||||
|
openFor: (agent) =>
|
||||||
|
set(s => (s.agent?.id === agent.id
|
||||||
|
? { agent }
|
||||||
|
: { agent, draft: '', compact: false, focused: false })),
|
||||||
|
|
||||||
|
close: () => set({ agent: null, compact: false, focused: false }),
|
||||||
|
setDraft: (draft) => set({ draft }),
|
||||||
|
setCompact: (compact) => set({ compact }),
|
||||||
|
setFocused: (focused) => set({ focused }),
|
||||||
|
}))
|
||||||
Reference in new issue
Block a user