feat(runde-5): Startseite mit Agenten-Meetingraum, sticky Chat und Noras Kanäle
Hauptnavigation und Startseite (§2) - Hauptseite «Übersicht» samt Route, Menüeintrag, Hook, Service und den nur dort verwendeten Widgets entfernt; /supply/dashboard leitet auf die Startseite um, damit Lesezeichen nicht ins Leere laufen. - «Meine Objekte» heisst im Menü «Startseite»; die frühere Überschrift «Objektverwaltung» heisst jetzt «Meine Objekte». - Die Startseite scrollt als Ganzes. Die Höhe des Meetingraums rechnet gegen die Viewport-Höhe, damit Titel und Balken «Filter & Übersicht» beim Einstieg auf jeder Monitorgrösse sichtbar bleiben (geprüft 1280–1920 px). Agenten-Meetingraum (§3) - Zentralperspektive aus clip-path-Flächen, ohne 3D-Engine und ohne Animationsbibliothek: rechts geschlossene Wand, links und gegenüber boden- bis deckenhohe Glasfronten mit Blick auf Rhein, Basler Münster, Altstadt und Wettsteinbrücke. - Sitzordnung, Zielrouten, Grössen und Plakettenversatz liegen in einer zentralen Konfiguration (meetingRoomSeats.ts). - Neuer Agent Giorgio, Personalverwalter, sitzt vis-à-vis und führt in «Meine Agenten». Er steht bewusst nicht in AGENT_WORKSPACES: er hat keine Arbeitsseite, sondern ist die Adminfläche selbst. - Auf schmalen Schirmen tritt eine vereinfachte Kachelanordnung an die Stelle der Perspektive; ausgeblendet wird kein Agent. Sticky Chat bei allen Agenten (§5, §9–§11) - Scrollt der Chatkopf unter den globalen Balken, übernimmt dort eine kompakte Fassung mit Porträt, Name, Frage und Eingabefeld. - Ein gemeinsamer Zustand (agentChatStore) statt zweier Chat-Instanzen: Eingabetext und Fokus überstehen den Wechsel in beide Richtungen. - Noras Seite scrollte bisher gar nicht — Chatkopf ausserhalb des Scrollbereichs, zwei getrennt scrollende Spalten darunter. Behoben über den Seitenaufbau, nicht über eine Nora-Sonderlösung. Nora: CRM-Kanal und gemeinsame Lead-Liste (§12, §13) - Dritter Kanal «CRM» mit eigenem Domänentyp, Mockdaten, Provider, Service und Hook nach dem bestehenden Schichtenmuster. - Die Reiter «KI-Signale»/«Netzwerk» sind einer gemeinsamen Liste gewichen. Jede Zeile trägt ihre Kanalherkunft als Badge; die Herkunft steht im Datenmodell (LeadChannel) und wird nicht aus der Darstellung geraten. - Der frühere Reiterbalken ist der Filter «Kanaltyp» mit Alle/KI Signal/ Netzwerk/CRM, Standard «Alle», mit Empty State und Rücksetzung. Produkttour und Profilmenü (§8) - Tour auf die neue Struktur umgeschrieben: Startseite, Meetingraum, Agenten, sticky Chat, Leadkanäle. «Übersicht» kommt nicht mehr vor, «Meine Objekte» bezeichnet nur noch den Objektbereich. - Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» entfernt. Geprüft: 413 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925). Im Browser: Navigation, Agentenklicks aller sechs Plätze, Kanalfilter und Detailschubladen, Sticky Chat bei vier Agenten, 1280–1920 px sowie Tablet- und Mobilbreite, keine 404 für Bilder oder Routen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-4
@@ -8,19 +8,19 @@ import { useSessionStore } from './stores/sessionStore'
|
||||
import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants'
|
||||
|
||||
// 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> = {
|
||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
||||
[WorkspaceType.SUPPLY]: ROUTES.SUPPLY.PROPERTIES,
|
||||
}
|
||||
|
||||
function RoleRedirect() {
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
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 SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||
@@ -54,7 +54,13 @@ function App() {
|
||||
|
||||
{/* Supply Workspace */}
|
||||
<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 />} />
|
||||
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
|
||||
<Route path="/supply/properties/:propertyId" element={<Properties />} />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img">
|
||||
<title>Giorgio</title>
|
||||
<defs>
|
||||
<linearGradient id="g-bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#efe9e0"/>
|
||||
<stop offset="1" stop-color="#ddd5c9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="g-suit" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2c3a4f"/>
|
||||
<stop offset="1" stop-color="#1c2637"/>
|
||||
</linearGradient>
|
||||
<clipPath id="g-clip">
|
||||
<rect x="0" y="0" width="200" height="200"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#g-clip)">
|
||||
<rect width="200" height="200" fill="url(#g-bg)"/>
|
||||
|
||||
<!-- Schultern und Sakko -->
|
||||
<path d="M100 128c34 0 62 20 68 48 3 13 4 20 4 24H28c0-4 1-11 4-24 6-28 34-48 68-48z" fill="url(#g-suit)"/>
|
||||
<!-- Hemdkragen -->
|
||||
<path d="M100 128l18 8-18 30-18-30z" fill="#f3f1ec"/>
|
||||
<path d="M100 166l-9-16 9 4 9-4z" fill="#7c5f3a"/>
|
||||
<path d="M100 154l7 5-3 41h-8l-3-41z" fill="#7c5f3a"/>
|
||||
<!-- Reversgrat -->
|
||||
<path d="M82 136l18 22-24 42-8-52z" fill="#26334a"/>
|
||||
<path d="M118 136l-18 22 24 42 8-52z" fill="#26334a"/>
|
||||
|
||||
<!-- Hals -->
|
||||
<path d="M84 104h32v28c0 4-16 10-16 10s-16-6-16-10z" fill="#c9a184"/>
|
||||
<path d="M84 110c9 7 23 7 32 0v8c-9 7-23 7-32 0z" fill="#b08a70" opacity=".5"/>
|
||||
|
||||
<!-- Kopf -->
|
||||
<ellipse cx="100" cy="80" rx="34" ry="40" fill="#dcb193"/>
|
||||
<!-- Ohren -->
|
||||
<ellipse cx="66" cy="82" rx="6" ry="9" fill="#d3a688"/>
|
||||
<ellipse cx="134" cy="82" rx="6" ry="9" fill="#d3a688"/>
|
||||
|
||||
<!-- Haar, grau meliert -->
|
||||
<path d="M100 34c22 0 36 14 36 33 0 5-1 10-2 13-2-3-3-9-3-14-8 4-20 6-31 6-11 0-21-2-28-6-1 5-2 11-4 14-1-3-2-8-2-13 0-19 12-33 34-33z" fill="#8d8d8f"/>
|
||||
<path d="M100 34c14 0 24 6 30 16-9-6-19-9-30-9s-21 3-30 9c6-10 16-16 30-16z" fill="#a5a5a7"/>
|
||||
|
||||
<!-- Brille -->
|
||||
<g fill="none" stroke="#3b4350" stroke-width="3">
|
||||
<rect x="72" y="70" width="24" height="18" rx="6"/>
|
||||
<rect x="104" y="70" width="24" height="18" rx="6"/>
|
||||
<path d="M96 78h8M72 76l-8 3M128 76l8 3"/>
|
||||
</g>
|
||||
<!-- Augen -->
|
||||
<ellipse cx="84" cy="79" rx="3" ry="3.4" fill="#3a3128"/>
|
||||
<ellipse cx="116" cy="79" rx="3" ry="3.4" fill="#3a3128"/>
|
||||
<!-- Brauen -->
|
||||
<path d="M74 63c6-3 14-3 20 0" stroke="#8d8d8f" stroke-width="3.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M106 63c6-3 14-3 20 0" stroke="#8d8d8f" stroke-width="3.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Nase und Mund -->
|
||||
<path d="M100 84v10c0 2-2 3-4 4" stroke="#bc8f74" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M89 106c7 4 15 4 22 0" stroke="#a9705a" stroke-width="3.2" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -3,8 +3,7 @@ import type { AssistantContext } from '../../domain/assistant'
|
||||
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
|
||||
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/supply/dashboard': 'Übersicht',
|
||||
'/supply/properties': 'Meine Objekte',
|
||||
'/supply/properties': 'Startseite',
|
||||
'/supply/match-center': 'Eingehende Bedarfe',
|
||||
'/supply/data-quality': 'Datenpflege',
|
||||
'/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 { RoleGuard } from './RoleGuard'
|
||||
export { ProtectedRoute } from './ProtectedRoute'
|
||||
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
|
||||
export { OrganizationSwitcher } from './OrganizationSwitcher'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react'
|
||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||
import { NotificationButton } from './NotificationButton'
|
||||
import { UserMenu } from './UserMenu'
|
||||
import { AgentChatStickyBar } from '../team'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -25,6 +26,12 @@ export interface TopBarProps {
|
||||
* Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides
|
||||
* stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie
|
||||
* geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über.
|
||||
*
|
||||
* Seit Runde 5 steht in der freien Mitte der kompakte Agentenchat, sobald der
|
||||
* ausführliche Chatkopf weggescrollt ist (§9). Er liegt im Balken selbst und
|
||||
* nicht darüber — deshalb kann er Tabellen und Filter nicht verdecken. Der
|
||||
* Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt, aber unter
|
||||
* Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt (§10).
|
||||
*/
|
||||
export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
return (
|
||||
@@ -33,15 +40,18 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
sx={{
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: theme => theme.zIndex.appBar,
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
px: { xs: 1.5, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{isMobile && (
|
||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||
<Menu size={20} />
|
||||
@@ -49,8 +59,10 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AgentChatStickyBar />
|
||||
|
||||
{/* Right side */}
|
||||
<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' } }}>
|
||||
<OrganizationContextBadge />
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typogra
|
||||
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> = {
|
||||
@@ -87,12 +86,10 @@ export function UserMenu() {
|
||||
Produkttour starten
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Demo role switcher */}
|
||||
<Box sx={{ px: 1, py: 1 }}>
|
||||
<DemoRoleSwitcher />
|
||||
</Box>
|
||||
{/* Der Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» ist entfernt
|
||||
(Runde 5, §8). Die Flächensuche existiert in diesem Stand nicht mehr;
|
||||
eine Umschaltung auf eine Rolle ohne erreichbare Seiten wäre eine
|
||||
Sackgasse im Profilmenü gewesen. */}
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -10,19 +10,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
|
||||
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'
|
||||
|
||||
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»', () => {
|
||||
it('steht direkt unter «Meine Objekte»', () => {
|
||||
it('steht direkt unter der «Startseite»', () => {
|
||||
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)
|
||||
|
||||
expect(objekte).toBeGreaterThanOrEqual(0)
|
||||
expect(agenten).toBe(objekte + 1)
|
||||
expect(startseite).toBeGreaterThanOrEqual(0)
|
||||
expect(agenten).toBe(startseite + 1)
|
||||
})
|
||||
|
||||
it('führt auf den Basispfad des Funktionsbereichs', () => {
|
||||
@@ -64,6 +81,7 @@ describe('Navigationseintrag «Meine Agenten»', () => {
|
||||
|
||||
describe('Seitentitel aus dem Pfad', () => {
|
||||
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.AGENT_FERDI)).toBe('Ferdi')
|
||||
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_BRUNO)).toBe('Bruno')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
CheckSquare,
|
||||
Search,
|
||||
@@ -63,11 +62,13 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
label: 'Verwaltung',
|
||||
abbreviation: 'VW',
|
||||
icon: Building2,
|
||||
firstPath: '/supply/dashboard',
|
||||
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||
chipColor: '#152642',
|
||||
navItems: [
|
||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||
// Runde 5, §2.1/§2.2: «Übersicht» ist entfernt, «Meine Objekte» heisst im
|
||||
// Menü «Startseite». Der Meetingraum 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,
|
||||
label: MY_AGENTS_LABEL,
|
||||
@@ -93,7 +94,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
label: 'Suche',
|
||||
abbreviation: 'SU',
|
||||
icon: Search,
|
||||
firstPath: '/supply/dashboard',
|
||||
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||
chipColor: '#1a7a4a',
|
||||
navItems: [],
|
||||
},
|
||||
|
||||
@@ -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 { Building2, ExternalLink } from 'lucide-react'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { ExtractedContact } from '../../domain/futureSignal'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Reiter, Auswahl und Rahmen.
|
||||
@@ -17,50 +15,6 @@ import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -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 { 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 { 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 { Property } from '../../domain/property'
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
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).
|
||||
|
||||
export { HinweisListItem, HinweisDetail } from './MarktHinweisPanels'
|
||||
export { HinweisDetail } from './MarktHinweisPanels'
|
||||
export { HinweisErfassenDialog } from './HinweisErfassenDialog'
|
||||
export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, ButtonBase, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { MeetingRoomScene } from './MeetingRoomScene'
|
||||
import { AgentSeat } from './AgentSeat'
|
||||
import { BaselViewFront } from './BaselViewFront'
|
||||
import { MEETING_ROOM_SEATS } from './meetingRoomSeats'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const R = DS_MEETING_ROOM.room
|
||||
const L = DS_MEETING_ROOM.label
|
||||
|
||||
/**
|
||||
* Höhe der globalen Kopfzeile plus des Bereichs, der unter dem Meetingraum
|
||||
* sichtbar bleiben muss: Titel «Meine Objekte» samt Untertitel und der Balken
|
||||
* «Filter & Übersicht» (Runde 5, §2.3).
|
||||
*
|
||||
* Deshalb wird gerechnet und nicht fest gesetzt: die Szene bekommt, was die
|
||||
* Viewport-Höhe hergibt, und auf einem 4K-Monitor bleibt derselbe Streifen
|
||||
* unten sichtbar wie auf einem 13-Zoll-Laptop.
|
||||
*/
|
||||
const TOP_BAR_PX = 64
|
||||
const KEEP_VISIBLE_BELOW_PX = 118
|
||||
const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX
|
||||
|
||||
/** Unter dieser Höhe würde die Szene zur Briefmarke — dann lieber scrollen. */
|
||||
const MIN_SCENE_PX = 300
|
||||
|
||||
/**
|
||||
* Agenten-Meetingraum auf der Startseite (Runde 5, §3).
|
||||
*
|
||||
* Der Betrachter sitzt am Kopfende des Tisches; die fünf Agenten sitzen an den
|
||||
* Längsseiten, Giorgio vis-à-vis vor der Fensterfront. Jeder Platz ist eine
|
||||
* ganze klickbare Einheit und führt auf seine Arbeitsseite, Giorgio in die
|
||||
* Personalverwaltung.
|
||||
*
|
||||
* Auf schmalen Schirmen bricht die Perspektive zusammen — dort steht statt der
|
||||
* Szene eine einfache Kachelreihe. Sie zeigt dieselben Agenten mit denselben
|
||||
* Zielen; ausgeblendet wird niemand (§3.7).
|
||||
*/
|
||||
export function AgentMeetingRoom() {
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const isNarrow = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const handleOpen = useCallback((path: string) => navigate(path), [navigate])
|
||||
|
||||
const height = {
|
||||
height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
|
||||
'@supports (height: 100dvh)': {
|
||||
height: `max(${MIN_SCENE_PX}px, calc(100dvh - ${RESERVED_PX}px))`,
|
||||
},
|
||||
}
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box component="section" aria-label="Agenten-Meetingraum" sx={{ ...height, position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Die Aussicht nimmt den Platz, den die Kacheln nicht brauchen — sonst
|
||||
klafft auf hohen Schirmen eine leere Wand zwischen beiden. */}
|
||||
<Box sx={{ position: 'relative', flex: 1, minHeight: 140, overflow: 'hidden' }}>
|
||||
<BaselViewFront />
|
||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' },
|
||||
gap: 1,
|
||||
p: 2,
|
||||
bgcolor: R.wall,
|
||||
borderTop: '1px solid',
|
||||
borderColor: R.skirting,
|
||||
}}
|
||||
>
|
||||
{MEETING_ROOM_SEATS.map(seat => (
|
||||
<ButtonBase
|
||||
key={seat.id}
|
||||
focusRipple
|
||||
onClick={() => handleOpen(seat.path)}
|
||||
aria-label={`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
p: 1, borderRadius: 2, justifyContent: 'flex-start',
|
||||
bgcolor: L.bg, border: '1px solid', borderColor: L.border,
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${L.focus}`, outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<AgentAvatar agent={{ id: seat.id, name: seat.name, role: seat.role }} size={38} />
|
||||
<Box sx={{ minWidth: 0, textAlign: 'left' }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', lineHeight: 1.2, color: DS_TEXT.primary }}>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.25, color: DS_TEXT.secondary }}>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Agenten-Meetingraum"
|
||||
sx={{ ...height, position: 'relative', overflow: 'hidden' }}
|
||||
>
|
||||
<MeetingRoomScene />
|
||||
{MEETING_ROOM_SEATS.map(seat => (
|
||||
<AgentSeat key={seat.id} seat={seat} onOpen={handleOpen} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { memo } from 'react'
|
||||
import { ButtonBase, Box, Typography } from '@mui/material'
|
||||
import { AgentAvatar } from '../team'
|
||||
import type { MeetingRoomSeat } from './meetingRoomSeats'
|
||||
import { SEAT_BASE_HEAD_PX, SeatSide } from './meetingRoomSeats'
|
||||
import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const L = DS_MEETING_ROOM.label
|
||||
const T = DS_MEETING_ROOM.table
|
||||
|
||||
export interface AgentSeatProps {
|
||||
seat: MeetingRoomSeat
|
||||
onOpen: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Platz am Besprechungstisch (Runde 5, §3.6/§3.7).
|
||||
*
|
||||
* Die gesamte Einheit aus Stuhllehne, Kopf und Plakette ist ein einziger
|
||||
* Schalter — nicht nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche
|
||||
* wäre auf jedem Laptop eine Zumutung; die Plakette darüber trägt ohnehin den
|
||||
* Namen und gehört zum selben Ziel.
|
||||
*
|
||||
* `ButtonBase` statt `<div onClick>`: damit sind Tastaturbedienung, Enter und
|
||||
* Leertaste, Fokusring und Rollen-Semantik ohne eigenen Code korrekt.
|
||||
*/
|
||||
export const AgentSeat = memo(function AgentSeat({ seat, onOpen }: AgentSeatProps) {
|
||||
const head = Math.round(SEAT_BASE_HEAD_PX * seat.scale)
|
||||
const isHead = seat.side === SeatSide.HEAD
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
focusRipple
|
||||
onClick={() => onOpen(seat.path)}
|
||||
aria-label={`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${seat.x}%`,
|
||||
top: `${seat.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: seat.zIndex,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
// Grosszügige, aber nicht überlappende Klickfläche rund um den Kopf.
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
transition: 'transform 0.15s ease, background-color 0.15s ease',
|
||||
'&:hover, &.Mui-focusVisible': {
|
||||
bgcolor: 'rgba(255,255,255,0.16)',
|
||||
transform: 'translate(-50%, -52%)',
|
||||
},
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${L.focus}`, outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Plakette über dem Kopf: Name und Funktion. Sie schwebt vollständig
|
||||
oberhalb des Porträts — läge sie auch nur teilweise darüber, verdeckte
|
||||
der Kopf sie, weil er später gezeichnet wird. Der seitliche Versatz aus
|
||||
der Sitzordnung fächert benachbarte Plaketten auseinander (§3.7).
|
||||
*/}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: `calc(50% + ${seat.labelDx}%)`,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
// Ohne `max-content` bemisst der Browser die Plakette an der schmalen
|
||||
// Klickfläche und bricht jeden Funktionsnamen um; `maxWidth` allein
|
||||
// schafft keinen Platz, es begrenzt nur.
|
||||
width: 'max-content',
|
||||
maxWidth: 190,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: L.bg,
|
||||
border: '1px solid',
|
||||
borderColor: L.border,
|
||||
boxShadow: '0 2px 8px rgba(15,25,35,0.18)',
|
||||
pointerEvents: 'none',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: seat.scale < 0.9 ? '0.75rem' : '0.8125rem',
|
||||
lineHeight: 1.2,
|
||||
color: DS_TEXT.primary,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: seat.scale < 0.9 ? '0.65rem' : '0.6875rem',
|
||||
lineHeight: 1.25,
|
||||
color: DS_TEXT.secondary,
|
||||
// Kurze Funktionen bleiben einzeilig; nur «Marktchancen / Leads»
|
||||
// bricht um — abgeschnitten wäre schlechter als zweizeilig.
|
||||
hyphens: 'auto',
|
||||
}}
|
||||
>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Stuhllehne hinter dem Kopf — am Kopfende sitzt Giorgio frontal. */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `${Math.round(head * 0.52)}px`,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: Math.round(head * (isHead ? 1.5 : 1.35)),
|
||||
height: Math.round(head * 0.8),
|
||||
borderRadius: `${Math.round(head * 0.22)}px ${Math.round(head * 0.22)}px 0 0`,
|
||||
bgcolor: T.chair,
|
||||
borderTop: `2px solid ${T.chairRim}`,
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentAvatar
|
||||
agent={{ id: seat.id, name: seat.name, role: seat.role }}
|
||||
size={head}
|
||||
loading="eager"
|
||||
ringColor={L.ring}
|
||||
ringWidth={2}
|
||||
/>
|
||||
</ButtonBase>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { memo } from 'react'
|
||||
import { DS_MEETING_ROOM } from '../../lib/ds'
|
||||
|
||||
const V = DS_MEETING_ROOM.view
|
||||
|
||||
/**
|
||||
* Blick durch die gegenüberliegende Fensterfront (Runde 5, §3.5).
|
||||
*
|
||||
* Das Büro steht in Kleinbasel nahe dem Claraturm, der Blick geht über den
|
||||
* Rhein auf die Grossbasler Altstadt mit dem Münster — die Blickrichtung der
|
||||
* beiden Referenzbilder aus dem Auftrag.
|
||||
*
|
||||
* Gezeichnet und nicht fotografiert: ein Foto in einem Fensterrahmen mit
|
||||
* gerechneter Perspektive verzieht sich auf jedem zweiten Monitor, und ein
|
||||
* fehlendes Bild wäre ein 404 im leeren Fenster. Das SVG skaliert stattdessen
|
||||
* mit dem Rahmen und braucht keine Netzanfrage.
|
||||
*/
|
||||
export const BaselViewFront = memo(function BaselViewFront() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 400 240"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
width="100%"
|
||||
height="100%"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mr-sky-front" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={V.skyTop} />
|
||||
<stop offset="1" stopColor={V.skyHorizon} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mr-river-front" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={V.riverTop} />
|
||||
<stop offset="1" stopColor={V.riverBottom} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Himmel und ferne Jurahügel */}
|
||||
<rect width="400" height="240" fill="url(#mr-sky-front)" />
|
||||
<path d="M0 128 60 116 120 124 190 110 260 122 330 112 400 122v18H0z" fill={V.hills} opacity=".55" />
|
||||
|
||||
{/* Altstadtsilhouette am gegenüberliegenden Ufer */}
|
||||
<g>
|
||||
<rect x="0" y="126" width="46" height="34" fill={V.facadeMid} />
|
||||
<path d="M0 126h46l-4-9H4z" fill={V.roof} />
|
||||
<rect x="46" y="120" width="38" height="40" fill={V.facadeLight} />
|
||||
<path d="M46 120h38l-5-10H51z" fill={V.roofDark} />
|
||||
<rect x="84" y="130" width="34" height="30" fill={V.facadeDark} />
|
||||
<path d="M84 130h34l-4-8H88z" fill={V.roof} />
|
||||
|
||||
{/* Basler Münster — zwei Türme, rotes Sandsteinmassiv, grünes Dach */}
|
||||
<g>
|
||||
<rect x="150" y="112" width="54" height="48" fill={V.minsterWall} />
|
||||
<path d="M150 112h54l-6-10h-42z" fill={V.minsterRoof} />
|
||||
<rect x="140" y="76" width="17" height="84" fill={V.minsterWall} />
|
||||
<rect x="197" y="76" width="17" height="84" fill={V.minsterWall} />
|
||||
<path d="M140 76h17l-8.5-32z" fill={V.spire} />
|
||||
<path d="M197 76h17l-8.5-32z" fill={V.spire} />
|
||||
<rect x="146" y="88" width="5" height="12" fill={V.window} opacity=".8" />
|
||||
<rect x="203" y="88" width="5" height="12" fill={V.window} opacity=".8" />
|
||||
<circle cx="177" cy="130" r="7" fill={V.window} opacity=".7" />
|
||||
</g>
|
||||
|
||||
<rect x="214" y="124" width="42" height="36" fill={V.facadeLight} />
|
||||
<path d="M214 124h42l-5-9h-32z" fill={V.roofDark} />
|
||||
<rect x="256" y="118" width="48" height="42" fill={V.facadeMid} />
|
||||
<path d="M256 118h48l-6-11h-36z" fill={V.roof} />
|
||||
<rect x="304" y="128" width="44" height="32" fill={V.facadeDark} />
|
||||
<path d="M304 128h44l-5-9h-34z" fill={V.roofDark} />
|
||||
<rect x="348" y="122" width="52" height="38" fill={V.facadeLight} />
|
||||
<path d="M348 122h52l-6-10h-40z" fill={V.roof} />
|
||||
</g>
|
||||
|
||||
{/* Fensterreihen der Altstadtfassaden */}
|
||||
<g fill={V.window} opacity=".45">
|
||||
{[10, 24, 56, 70, 92, 106, 222, 236, 266, 282, 314, 330, 358, 374].map(x => (
|
||||
<rect key={x} x={x} y="136" width="6" height="9" />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Uferpromenade mit Baumreihe */}
|
||||
<rect x="0" y="158" width="400" height="12" fill={V.quay} />
|
||||
<g>
|
||||
{[18, 62, 108, 232, 276, 322, 366].map(x => (
|
||||
<g key={x}>
|
||||
<rect x={x - 1} y="150" width="3" height="12" fill={V.treeDark} />
|
||||
<ellipse cx={x} cy="146" rx="15" ry="12" fill={V.treeLight} />
|
||||
<ellipse cx={x - 6} cy="150" rx="10" ry="8" fill={V.treeDark} opacity=".7" />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Rhein */}
|
||||
<rect x="0" y="170" width="400" height="70" fill="url(#mr-river-front)" />
|
||||
<g stroke={V.riverGlint} strokeWidth="1.6" strokeLinecap="round" fill="none" opacity=".7">
|
||||
<path d="M18 184h44M96 192h58M198 182h40M268 196h56M60 210h70M210 216h84M330 206h48" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { memo } from 'react'
|
||||
import { DS_MEETING_ROOM } from '../../lib/ds'
|
||||
|
||||
const V = DS_MEETING_ROOM.view
|
||||
|
||||
/**
|
||||
* Blick durch die linke Glasfront (Runde 5, §3.5).
|
||||
*
|
||||
* Rheinabwärts: der weitere Verlauf des Rheins, die Altstadt am rechten Ufer
|
||||
* und die Wettsteinbrücke. Das Bild ist stärker längs angelegt als der Blick
|
||||
* nach vorne — die linke Front wird perspektivisch schmal und hoch beschnitten,
|
||||
* deshalb steht die Brücke bewusst weit oben statt am Bildrand.
|
||||
*/
|
||||
export const BaselViewLeft = memo(function BaselViewLeft() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 240 400"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
width="100%"
|
||||
height="100%"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mr-sky-left" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={V.skyTop} />
|
||||
<stop offset="1" stopColor={V.skyHorizon} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mr-river-left" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={V.riverTop} />
|
||||
<stop offset="1" stopColor={V.riverBottom} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="240" height="400" fill="url(#mr-sky-left)" />
|
||||
<path d="M0 150 50 142 110 148 170 138 240 146v20H0z" fill={V.hills} opacity=".5" />
|
||||
|
||||
{/* Wettsteinbrücke — drei flache Bögen über dem Rhein */}
|
||||
<g>
|
||||
<rect x="0" y="176" width="240" height="9" fill={V.bridge} />
|
||||
<rect x="0" y="185" width="240" height="4" fill={V.bridgeDark} />
|
||||
<path d="M14 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
||||
<path d="M94 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
||||
<path d="M174 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
||||
<rect x="62" y="185" width="12" height="26" fill={V.bridgeDark} />
|
||||
<rect x="146" y="185" width="12" height="26" fill={V.bridgeDark} />
|
||||
<g fill={V.bridge}>
|
||||
{[10, 42, 74, 106, 138, 170, 202, 234].map(x => (
|
||||
<rect key={x} x={x} y="168" width="3" height="8" />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Altstadtzeile am rechten Ufer, hinter der Brücke */}
|
||||
<g>
|
||||
<rect x="0" y="150" width="40" height="26" fill={V.facadeMid} />
|
||||
<path d="M0 150h40l-4-8H4z" fill={V.roof} />
|
||||
<rect x="40" y="146" width="36" height="30" fill={V.facadeLight} />
|
||||
<path d="M40 146h36l-5-9H45z" fill={V.roofDark} />
|
||||
<rect x="76" y="152" width="32" height="24" fill={V.facadeDark} />
|
||||
<path d="M76 152h32l-4-8H80z" fill={V.roof} />
|
||||
<rect x="108" y="144" width="40" height="32" fill={V.facadeLight} />
|
||||
<path d="M108 144h40l-5-9h-30z" fill={V.roofDark} />
|
||||
<rect x="148" y="150" width="38" height="26" fill={V.facadeMid} />
|
||||
<path d="M148 150h38l-4-8h-30z" fill={V.roof} />
|
||||
<rect x="186" y="146" width="54" height="30" fill={V.facadeDark} />
|
||||
<path d="M186 146h54l-6-9h-42z" fill={V.roofDark} />
|
||||
</g>
|
||||
<g fill={V.window} opacity=".4">
|
||||
{[8, 20, 48, 60, 84, 116, 128, 156, 168, 196, 214].map(x => (
|
||||
<rect key={x} x={x} y="160" width="5" height="8" />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Uferkante mit Bäumen */}
|
||||
<rect x="0" y="211" width="240" height="10" fill={V.quay} />
|
||||
<g>
|
||||
{[20, 56, 100, 134, 178, 216].map(x => (
|
||||
<g key={x}>
|
||||
<rect x={x - 1} y="203" width="3" height="10" fill={V.treeDark} />
|
||||
<ellipse cx={x} cy="200" rx="13" ry="10" fill={V.treeLight} />
|
||||
<ellipse cx={x - 5} cy="203" rx="9" ry="7" fill={V.treeDark} opacity=".65" />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Rheinverlauf bis in den Vordergrund */}
|
||||
<rect x="0" y="221" width="240" height="179" fill="url(#mr-river-left)" />
|
||||
<g stroke={V.riverGlint} strokeWidth="1.6" strokeLinecap="round" fill="none" opacity=".65">
|
||||
<path d="M12 240h48M96 252h62M20 278h74M128 288h84M40 316h96M10 350h70M120 364h96M60 386h110" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { memo } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { BaselViewFront } from './BaselViewFront'
|
||||
import { BaselViewLeft } from './BaselViewLeft'
|
||||
import { DS_MEETING_ROOM } from '../../lib/ds'
|
||||
|
||||
const R = DS_MEETING_ROOM.room
|
||||
const T = DS_MEETING_ROOM.table
|
||||
|
||||
/**
|
||||
* Der leere Besprechungsraum — Decke, Wände, Boden, Fenster, Tisch (Runde 5, §3).
|
||||
*
|
||||
* Zentralperspektive mit einem Fluchtpunkt in der Bildmitte: der Betrachter
|
||||
* sitzt am Kopfende, der Tisch läuft von ihm weg zur gegenüberliegenden
|
||||
* Fensterfront. Rechts eine geschlossene Wand, links und gegenüber
|
||||
* boden- bis deckenhohe Glasfronten (§3.5).
|
||||
*
|
||||
* Umgesetzt als Flächen mit `clip-path`, nicht mit CSS-3D-Transformationen:
|
||||
* Trapeze in Prozentkoordinaten skalieren mit jeder Rahmengrösse mit, während
|
||||
* `rotateY`/`perspective` bei wechselnder Höhe neu justiert werden müsste. Es
|
||||
* braucht dafür weder eine 3D-Engine noch eine Animationsbibliothek (§1).
|
||||
*
|
||||
* Senkrechten bleiben in der Einpunktperspektive senkrecht — die Pfosten beider
|
||||
* Fensterfronten stehen deshalb gerade, nur die waagrechten Rahmen laufen zum
|
||||
* Fluchtpunkt.
|
||||
*
|
||||
* Die Zahlen unten sind die Geometrie des Raums und stehen deshalb hier statt
|
||||
* in einer Konstantendatei — sie ergeben nur zusammen mit den Flächen Sinn.
|
||||
*/
|
||||
|
||||
/** Fluchtebene: obere und untere Kante der gegenüberliegenden Wand, in Prozent. */
|
||||
const HORIZON_TOP = 26
|
||||
const HORIZON_BOTTOM = 62
|
||||
/** Seitliche Kanten der gegenüberliegenden Wand. */
|
||||
const WALL_LEFT = 30
|
||||
const WALL_RIGHT = 70
|
||||
|
||||
export const MeetingRoomScene = memo(function MeetingRoomScene() {
|
||||
return (
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: R.wall }}>
|
||||
{/* Decke */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `linear-gradient(180deg, ${R.ceiling} 0%, ${R.ceilingShadow} 100%)`,
|
||||
clipPath: `polygon(0% 0%, 100% 0%, ${WALL_RIGHT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_TOP}%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Boden */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `linear-gradient(180deg, ${R.floorFar} 0%, ${R.floorNear} 100%)`,
|
||||
clipPath: `polygon(0% 100%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%, 100% 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Rechte Seite: geschlossene Wand ── */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `linear-gradient(100deg, ${R.wallShade} 0%, ${R.wallAccent} 60%, ${R.wall} 100%)`,
|
||||
clipPath: `polygon(100% 0%, 100% 100%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%, ${WALL_RIGHT}% ${HORIZON_TOP}%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Sockelleiste — trennt Wand und Boden sichtbar voneinander */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: R.skirting,
|
||||
clipPath: `polygon(100% 100%, 100% 95%, ${WALL_RIGHT}% ${HORIZON_BOTTOM - 1.6}%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Linke Seite: boden- bis deckenhohe Glasfront, Blick rheinabwärts ── */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
clipPath: `polygon(0% 0%, ${WALL_LEFT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, 0% 100%)`,
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Das Bild deckt nur den Wandstreifen ab, nicht die ganze Szene: bei
|
||||
`inset: 0` streckte `preserveAspectRatio="slice"` es auf Szenenbreite,
|
||||
und im schmalen Ausschnitt blieben nur riesige Baumkronen übrig.
|
||||
*/}
|
||||
<Box sx={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${WALL_LEFT + 1}%`, overflow: 'hidden' }}>
|
||||
<BaselViewLeft />
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
||||
|
||||
{/* Senkrechte Pfosten */}
|
||||
{[9, 17.5, 25.5].map(x => (
|
||||
<Box
|
||||
key={x}
|
||||
sx={{ position: 'absolute', left: `${x}%`, top: 0, bottom: 0, width: 5, bgcolor: R.mullion, opacity: 0.9 }}
|
||||
/>
|
||||
))}
|
||||
{/* Waagrechter Kämpfer — läuft mit der Perspektive zum Fluchtpunkt */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
||||
clipPath: `polygon(0% 33%, ${WALL_LEFT}% ${HORIZON_TOP + 8}%, ${WALL_LEFT}% ${HORIZON_TOP + 9.2}%, 0% 34.6%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Deckenanschluss und Bodenschiene der Front */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
||||
clipPath: `polygon(0% 0%, ${WALL_LEFT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_TOP + 1.4}%, 0% 2.4%)`,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
||||
clipPath: `polygon(0% 97.6%, ${WALL_LEFT}% ${HORIZON_BOTTOM - 1.4}%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, 0% 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Streiflicht auf dem Glas */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, opacity: 0.4,
|
||||
background: `linear-gradient(100deg, transparent 0%, ${R.glassGlare} 22%, transparent 45%)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Gegenüberliegende Fensterfront: Rhein, Altstadt, Münster ── */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${WALL_LEFT}%`,
|
||||
right: `${100 - WALL_RIGHT}%`,
|
||||
top: `${HORIZON_TOP}%`,
|
||||
bottom: `${100 - HORIZON_BOTTOM}%`,
|
||||
overflow: 'hidden',
|
||||
border: '5px solid',
|
||||
borderColor: R.frame,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<BaselViewFront />
|
||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
||||
{/* Zwei Pfosten teilen die Front in drei Felder, dazu ein Kämpfer */}
|
||||
{[33.3, 66.6].map(x => (
|
||||
<Box key={x} sx={{ position: 'absolute', left: `${x}%`, top: 0, bottom: 0, width: 5, bgcolor: R.mullion }} />
|
||||
))}
|
||||
<Box sx={{ position: 'absolute', left: 0, right: 0, top: '22%', height: 4, bgcolor: R.mullion, opacity: 0.75 }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, opacity: 0.45,
|
||||
background: `linear-gradient(115deg, ${R.glassGlare} 0%, transparent 34%, transparent 66%, ${R.glassGlare} 100%)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Besprechungstisch: vom Betrachter zur Fensterfront verlaufend ── */}
|
||||
{/* Schlagschatten auf dem Boden — ohne ihn schwebt die Platte über nichts */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: T.shadow, filter: 'blur(10px)',
|
||||
clipPath: 'polygon(38% 70%, 62% 70%, 90% 104%, 10% 104%)',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `linear-gradient(180deg, ${T.topFar} 60%, ${T.topNear} 100%)`,
|
||||
clipPath: 'polygon(40% 68.5%, 60% 68.5%, 86% 104%, 14% 104%)',
|
||||
}}
|
||||
/>
|
||||
{/* Hintere Tischkante — dünne dunkle Linie, gibt der Platte Dicke */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: T.edge,
|
||||
clipPath: 'polygon(40% 68.5%, 60% 68.5%, 60.4% 69.6%, 39.6% 69.6%)',
|
||||
}}
|
||||
/>
|
||||
{/* Heller Mittelstreifen — führt den Blick zur Fensterfront */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, bgcolor: T.inlay, opacity: 0.14,
|
||||
clipPath: 'polygon(47% 69.6%, 53% 69.6%, 58% 104%, 42% 104%)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
// Agenten-Meetingraum auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { AgentMeetingRoom } from './AgentMeetingRoom'
|
||||
export { MEETING_ROOM_SEATS, SEAT_BASE_HEAD_PX, SeatSide } from './meetingRoomSeats'
|
||||
export type { MeetingRoomSeat } from './meetingRoomSeats'
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Property On — Sitzordnung im Agenten-Meetingraum (Runde 5, §3.7).
|
||||
*
|
||||
* Eine einzige Tabelle bestimmt, wer wo sitzt, wie gross er erscheint, wohin
|
||||
* sein Klick führt und wo seine Plakette hängt. Wer einen Agenten umsetzt,
|
||||
* ergänzt oder umbenennt, ändert genau diese Datei — nicht die Szene.
|
||||
*
|
||||
* Koordinaten sind Prozentwerte des Szenenrahmens, nicht Pixel: der Raum wird
|
||||
* anhand der Viewport-Höhe berechnet und darf auf jedem Monitor eine andere
|
||||
* Grösse haben, ohne dass die Sitzordnung verrutscht.
|
||||
*
|
||||
* Die Tiefe steckt in `scale` und `zIndex`: weiter hinten heisst kleiner und
|
||||
* weiter unten in der Stapelfolge. Beides ist bewusst von Hand gesetzt und
|
||||
* nicht aus `y` gerechnet — die Tischgeometrie ist keine lineare Funktion.
|
||||
*/
|
||||
|
||||
import { AGENT_WORKSPACES, AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
|
||||
|
||||
/** Auf welcher Seite des Tisches ein Platz liegt — steuert die Blickrichtung. */
|
||||
export const SeatSide = {
|
||||
LEFT: 'LEFT',
|
||||
RIGHT: 'RIGHT',
|
||||
HEAD: 'HEAD',
|
||||
} as const
|
||||
export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
|
||||
|
||||
export interface MeetingRoomSeat {
|
||||
/** Identisch mit der Agenten-ID in `AGENT_PHOTOS` — liefert das Porträt. */
|
||||
id: string
|
||||
name: string
|
||||
/** Funktionsbezeichnung; steht unter dem Namen auf der Plakette. */
|
||||
role: string
|
||||
/** Zielseite des Klicks auf die gesamte Sitzeinheit. */
|
||||
path: string
|
||||
side: SeatSide
|
||||
/** Mittelpunkt des Kopfes in Prozent der Szenenbreite bzw. -höhe. */
|
||||
x: number
|
||||
y: number
|
||||
/** Grössenfaktor gegenüber der Basisgrösse — bildet die Raumtiefe ab. */
|
||||
scale: number
|
||||
/**
|
||||
* Seitlicher Versatz der Plakette in Prozentpunkten. Ohne ihn stünden die
|
||||
* Plaketten benachbarter Plätze übereinander; sie fächern deshalb nach
|
||||
* aussen auf (§3.7 «Labels bei Überlappung intelligent versetzen»).
|
||||
*/
|
||||
labelDx: number
|
||||
/** Stapelfolge: vorne sitzende Agenten verdecken weiter hinten sitzende. */
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
type SeatPlacement = Omit<MeetingRoomSeat, 'id' | 'name' | 'role' | 'path'>
|
||||
|
||||
/**
|
||||
* Plätze der fünf Agenten, nach Agenten-ID statt nach Reihenfolge — so
|
||||
* verrutscht die Sitzordnung nicht still, wenn `AGENT_WORKSPACES` einmal
|
||||
* umsortiert wird.
|
||||
*
|
||||
* Die Vergabe läuft im Zickzack von hinten nach vorn (hinten links, hinten
|
||||
* rechts, Mitte links, Mitte rechts, vorne links) entlang der Reihenfolge des
|
||||
* Seitenmenüs: so bleibt der Arbeitsablauf auch am Tisch ablesbar und keine
|
||||
* Tischseite wirkt leer.
|
||||
*/
|
||||
const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
|
||||
ferdi: { side: SeatSide.LEFT, x: 31, y: 65, scale: 0.86, labelDx: -3, zIndex: 20 },
|
||||
bruno: { side: SeatSide.RIGHT, x: 69, y: 65, scale: 0.86, labelDx: 3, zIndex: 20 },
|
||||
livia: { side: SeatSide.LEFT, x: 22, y: 76, scale: 1.00, labelDx: -2, zIndex: 30 },
|
||||
nora: { side: SeatSide.RIGHT, x: 78, y: 76, scale: 1.00, labelDx: 2, zIndex: 30 },
|
||||
sina: { side: SeatSide.LEFT, x: 11, y: 88, scale: 1.14, labelDx: 1, zIndex: 40 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Notplatz für einen Agenten ohne eigenen Eintrag oben. Kein Agent darf
|
||||
* verschwinden, nur weil jemand die Sitzordnung zu ergänzen vergisst (§3.7).
|
||||
* Er landet dann sichtbar vorne rechts statt lautlos im Nichts.
|
||||
*/
|
||||
const FALLBACK_PLACEMENT: SeatPlacement = {
|
||||
side: SeatSide.RIGHT, x: 89, y: 88, scale: 1.14, labelDx: -1, zIndex: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Giorgio sitzt vis-à-vis am Kopfende gegenüber dem Betrachter, vor der
|
||||
* Fensterfront. Er ist der einzige Platz, dessen Klick nicht auf eine
|
||||
* Arbeitsseite führt, sondern in die Personalverwaltung.
|
||||
*/
|
||||
const SUPERVISOR_PLACEMENT: SeatPlacement = {
|
||||
side: SeatSide.HEAD, x: 50, y: 56, scale: 0.80, labelDx: 0, zIndex: 15,
|
||||
}
|
||||
|
||||
export const MEETING_ROOM_SEATS: MeetingRoomSeat[] = [
|
||||
...AGENT_WORKSPACES.map(agent => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
path: agent.path,
|
||||
...(AGENT_PLACEMENTS[agent.id] ?? FALLBACK_PLACEMENT),
|
||||
})),
|
||||
{
|
||||
id: AGENT_SUPERVISOR.id,
|
||||
name: AGENT_SUPERVISOR.name,
|
||||
role: AGENT_SUPERVISOR.role,
|
||||
path: AGENT_SUPERVISOR.path,
|
||||
...SUPERVISOR_PLACEMENT,
|
||||
},
|
||||
]
|
||||
|
||||
/** Kopfgrösse bei `scale: 1`, in Pixel. Alle übrigen Grössen leiten sich ab. */
|
||||
export const SEAT_BASE_HEAD_PX = 62
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,133 +9,180 @@ import {
|
||||
Button,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Zap,
|
||||
Search,
|
||||
Mic,
|
||||
Sparkles,
|
||||
Newspaper,
|
||||
FileCheck,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
Filter,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Radar,
|
||||
Users,
|
||||
} 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,
|
||||
color,
|
||||
text,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
text: string
|
||||
/**
|
||||
* Produkttour (Runde 5, §8).
|
||||
*
|
||||
* Sie erklärt die Anwendung, wie sie seit dieser Runde aufgebaut ist: der
|
||||
* entfernte Bereich «Übersicht» kommt nicht mehr vor, der Menüeintrag heisst
|
||||
* «Startseite», und «Meine Objekte» bezeichnet ausschliesslich den
|
||||
* Objektbereich unterhalb des Meetingraums.
|
||||
*
|
||||
* 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 (
|
||||
<Box className="flex items-center gap-2 mt-2">
|
||||
<Box sx={{ color, flexShrink: 0 }}>{icon}</Box>
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide0() {
|
||||
function SlideStartseite() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Building2 size={48} color="#152642" />
|
||||
</Box>
|
||||
<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">
|
||||
<SlideFrame
|
||||
icon={<Building2 size={48} color={DS_BRAND.main} />}
|
||||
title={`Ihre ${HOME_NAV_LABEL}`}
|
||||
lead="Der Einstieg ist der Besprechungsraum Ihrer digitalen Mitarbeitenden — darunter folgt Ihr Objektbestand."
|
||||
>
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Verifizierte Portfolio-Objekte"
|
||||
icon={<MousePointerClick size={18} />}
|
||||
color={DS_BRAND.main}
|
||||
text="Ein Klick auf einen Agenten am Tisch öffnet direkt dessen Arbeitsbereich."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Externer Markt & Inserate"
|
||||
icon={<Building2 size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`}
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Zap size={18} />}
|
||||
color="#7c3aed"
|
||||
text="Future Availability Intelligence"
|
||||
icon={<Filter size={18} />}
|
||||
color={DS_ACCENT.warning.main}
|
||||
text="Über «Filter & Übersicht» schränken Sie den Bestand nach Nutzungsart, Status und Verfügbarkeit ein."
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide1() {
|
||||
function SlideAgenten() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Search size={48} color="#152642" />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Bedarf beschreiben — KI findet Matches
|
||||
<SlideFrame
|
||||
icon={<Users size={48} color={DS_BRAND.main} />}
|
||||
title={MY_AGENTS_LABEL}
|
||||
lead="Fünf digitale Mitarbeitende mit festem Aufgabenprofil — jeder mit eigenem Arbeitsbereich."
|
||||
>
|
||||
<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 variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien
|
||||
und findet die besten Matches aus Portfolio, Markt und Future Availability.
|
||||
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.2, color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</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>
|
||||
<FeatureRow
|
||||
icon={<ClipboardList size={18} />}
|
||||
color={DS_ACCENT.violet.main}
|
||||
text="Giorgio am Kopfende des Tisches führt in die Personalverwaltung: Aufgaben, Kanäle & Systeme, Bearbeitungsverlauf."
|
||||
/>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide2() {
|
||||
function SlideChat() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Zap size={48} color="#7c3aed" />
|
||||
</Box>
|
||||
<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">
|
||||
<SlideFrame
|
||||
icon={<MessageSquare size={48} color={DS_BRAND.main} />}
|
||||
title="Jeder Agent ist ansprechbar"
|
||||
lead="Auf jeder Agentenseite steht oben ein Eingabefeld — Sie schreiben dem Agenten wie einem Menschen."
|
||||
>
|
||||
<FeatureRow
|
||||
icon={<Newspaper size={18} />}
|
||||
color="#d97706"
|
||||
text="Presse & Stelleninserate"
|
||||
icon={<ArrowRight size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text="Beim Scrollen rutscht der Chat kompakt in die oberste Leiste und bleibt erreichbar."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<FileCheck size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Baubewilligungen & Berichte"
|
||||
icon={<MessageSquare size={18} />}
|
||||
color={DS_ACCENT.warning.main}
|
||||
text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter."
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
const [open, setOpen] = useState(() => {
|
||||
return localStorage.getItem('property_match_welcomed') === null
|
||||
})
|
||||
const [open, setOpen] = useState(() => localStorage.getItem('property_match_welcomed') === null)
|
||||
const [activeStep, setActiveStep] = useState(0)
|
||||
const lastStep = SLIDES.length - 1
|
||||
|
||||
function dismiss() {
|
||||
localStorage.setItem('property_match_welcomed', '1')
|
||||
@@ -142,23 +190,19 @@ export function WelcomeDialog() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||
{SLIDES[activeStep]}
|
||||
<MobileStepper
|
||||
variant="dots"
|
||||
steps={3}
|
||||
steps={SLIDES.length}
|
||||
position="static"
|
||||
activeStep={activeStep}
|
||||
sx={{ mt: 3, background: 'transparent' }}
|
||||
backButton={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setActiveStep((s) => s - 1)}
|
||||
onClick={() => setActiveStep(s => s - 1)}
|
||||
disabled={activeStep === 0}
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
>
|
||||
@@ -166,16 +210,12 @@ export function WelcomeDialog() {
|
||||
</Button>
|
||||
}
|
||||
nextButton={
|
||||
activeStep === 2 ? (
|
||||
activeStep === lastStep ? (
|
||||
<Button size="small" variant="contained" onClick={dismiss}>
|
||||
Los geht's
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setActiveStep((s) => s + 1)}
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
>
|
||||
<Button size="small" onClick={() => setActiveStep(s => s + 1)} endIcon={<ArrowRight size={16} />}>
|
||||
Weiter
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -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 { KpiGrid } from './KpiGrid'
|
||||
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 type { PropertyCardProps } from './PropertyCard'
|
||||
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 type { KeyboardEvent } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
|
||||
export interface AgentWorkspaceHeroProps {
|
||||
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 —
|
||||
* 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
|
||||
* wären fünf Stellen, an denen dieselbe Änderung nachgezogen werden müsste.
|
||||
*
|
||||
* Das Absenden ist Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
* Seit Runde 5 meldet sie zusätzlich ihren Sichtbarkeitszustand: scrollt sie
|
||||
* 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) {
|
||||
const [message, setMessage] = useState('')
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const compact = useAgentChatStore(s => s.compact)
|
||||
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() {
|
||||
const text = message.trim()
|
||||
if (!text) return
|
||||
setMessage('')
|
||||
showToast(`Nachricht an ${name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, 'info')
|
||||
}
|
||||
// Der Agent der Seite ist die Identität des Chats — auch für den sticky Balken,
|
||||
// der ausserhalb dieser Seite im globalen Kopf steht.
|
||||
useEffect(() => {
|
||||
openFor({ id: agentId, name, role })
|
||||
return () => close()
|
||||
}, [agentId, name, role, openFor, close])
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ein `IntersectionObserver` statt eines Scroll-Listeners: er meldet sich nur
|
||||
* beim Zustandswechsel und nicht bei jedem Scrollschritt, und er funktioniert
|
||||
* 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 (
|
||||
<Box
|
||||
component="section"
|
||||
ref={sectionRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -78,23 +100,26 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
||||
{AGENT_CHAT_PROMPT}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
||||
<Box {...{ [AGENT_CHAT_MARKER]: '' }} sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
inputRef={inputRef}
|
||||
placeholder={`Nachricht an ${name} senden`}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
aria-label={`Nachricht an ${name} senden`}
|
||||
sx={{ alignSelf: 'flex-end', color: DS_TEXT.primary }}
|
||||
>
|
||||
|
||||
@@ -50,6 +50,10 @@ import gian from '../../assets/team/gian.jpg'
|
||||
import ida from '../../assets/team/ida.jpg'
|
||||
import jana from '../../assets/team/jana.jpg'
|
||||
import zeno from '../../assets/team/zeno.jpg'
|
||||
// Giorgio ist erst in Runde 5 dazugekommen und hat kein Porträt aus dem
|
||||
// Konzeptdokument. Statt den Kopf eines bestehenden Agenten zweitzuverwenden —
|
||||
// jeder Kopf gehört genau einer Person — trägt er ein gezeichnetes Porträt.
|
||||
import giorgio from '../../assets/team/giorgio.svg'
|
||||
|
||||
export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||
bruno,
|
||||
@@ -86,4 +90,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||
ida,
|
||||
jana,
|
||||
zeno,
|
||||
giorgio,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export { ChannelsSection } from './ChannelsSection'
|
||||
|
||||
// Gemeinsame Bausteine der fünf Agentenseiten
|
||||
export { AgentWorkspaceHero } from './AgentWorkspaceHero'
|
||||
export { AgentChatStickyBar } from './AgentChatStickyBar'
|
||||
export { useAgentChat } from './useAgentChat'
|
||||
export { ObjectDeepLink } from './ObjectDeepLink'
|
||||
|
||||
// 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,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,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,20 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
||||
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
||||
return AGENT_WORKSPACES.find(a => a.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Giorgio — Personalverwalter und Vorgesetzter der fünf Agenten (Runde 5, §6).
|
||||
*
|
||||
* Bewusst nicht Teil von `AGENT_WORKSPACES`: Giorgio 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 Seitenmenü und als sechster Mitarbeitender in der Belegschaft —
|
||||
* beides wäre falsch.
|
||||
*/
|
||||
export const AGENT_SUPERVISOR: AgentWorkspace = {
|
||||
id: 'giorgio',
|
||||
name: 'Giorgio',
|
||||
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_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
||||
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
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
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',
|
||||
/**
|
||||
* 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',
|
||||
FUTURE_AVAILABILITY: '/supply/future-availability',
|
||||
DATA_QUALITY: '/supply/data-quality',
|
||||
@@ -258,6 +270,15 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
||||
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
||||
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
|
||||
* (`?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). */
|
||||
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) ───────────────────────────────────
|
||||
|
||||
export const EXPOSE_LEAD_TABS = {
|
||||
|
||||
@@ -291,3 +291,93 @@ 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)' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||
}
|
||||
|
||||
// ── Agenten-Meetingraum auf der Startseite (Runde 5, §3) ─────────────────────
|
||||
|
||||
/**
|
||||
* Farbwelt der Meetingraum-Szene.
|
||||
*
|
||||
* Die Szene ist eine reine 2D-/CSS-Komposition ohne 3D-Bibliothek — sie besteht
|
||||
* deshalb aus ungewöhnlich vielen Einzelflächen: Decke, Wände, Boden, Tischplatte,
|
||||
* Fensterrahmen sowie die Basler Aussicht mit Rhein, Altstadt und Münster.
|
||||
*
|
||||
* Sie stehen hier statt in der Komponente, weil eine Szene mit vierzig
|
||||
* Farbstufen in der Komponente nichts anderes wäre als vierzig fest verdrahtete
|
||||
* Hexwerte (CLAUDE.md §8). Bewusst eigene Gruppe: es sind Szenenfarben, keine
|
||||
* semantischen Zustandsfarben — ein Dachziegelrot darf nie versehentlich als
|
||||
* Fehlerfarbe verwendet werden.
|
||||
*/
|
||||
export const DS_MEETING_ROOM = {
|
||||
/** Innenraum: Decke, Wände, Boden, Sockelleisten. */
|
||||
room: {
|
||||
ceiling: '#f4f2ee',
|
||||
ceilingShadow: '#e6e3dd',
|
||||
wall: '#eae6df',
|
||||
wallShade: '#ddd8cf',
|
||||
wallAccent: '#d9d3c8',
|
||||
floorNear: '#b9a893',
|
||||
floorFar: '#d8ccbb',
|
||||
skirting: '#cfc7ba',
|
||||
mullion: '#5d6773',
|
||||
frame: '#7b848f',
|
||||
glassTint: 'rgba(214,232,240,0.18)',
|
||||
glassGlare: 'rgba(255,255,255,0.30)',
|
||||
},
|
||||
/** Besprechungstisch samt Stühlen. */
|
||||
table: {
|
||||
topNear: '#6b5a48',
|
||||
topFar: '#8a7359',
|
||||
edge: '#4e4133',
|
||||
inlay: '#f6f3ee',
|
||||
chair: '#3b4450',
|
||||
chairRim: '#2c333d',
|
||||
shadow: 'rgba(45,35,25,0.28)',
|
||||
},
|
||||
/** Basler Aussicht: Himmel, Rhein, Altstadt, Münster, Wettsteinbrücke. */
|
||||
view: {
|
||||
skyTop: '#a8ccdf',
|
||||
skyHorizon: '#e2ecef',
|
||||
hills: '#b3c2c0',
|
||||
riverTop: '#7fa6ac',
|
||||
riverBottom: '#4d7d86',
|
||||
riverGlint: 'rgba(255,255,255,0.45)',
|
||||
quay: '#c8bda9',
|
||||
treeDark: '#4e6b4a',
|
||||
treeLight: '#6b8a5c',
|
||||
facadeLight: '#efe8dc',
|
||||
facadeMid: '#ded3c2',
|
||||
facadeDark: '#c9bda9',
|
||||
roof: '#a2543f',
|
||||
roofDark: '#8a4433',
|
||||
minsterWall: '#b8654a',
|
||||
minsterRoof: '#5f7f6d',
|
||||
spire: '#8f4a38',
|
||||
bridge: '#b9b2a6',
|
||||
bridgeDark: '#9a9287',
|
||||
window: '#8fa3ad',
|
||||
},
|
||||
/** Agentenplakette über dem Kopf. */
|
||||
label: {
|
||||
bg: 'rgba(255,255,255,0.94)',
|
||||
border: 'rgba(21,38,66,0.14)',
|
||||
ring: 'rgba(255,255,255,0.85)',
|
||||
focus: '#b8975a',
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── 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 { Box, Chip, Skeleton, Tab, Tabs, Typography, Button } from '@mui/material'
|
||||
import { Plus, Sparkles, Users } from 'lucide-react'
|
||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
||||
import { useMarktHinweise } from '../../hooks/useMarktHinweise'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { AgentWorkspaceHero } from '../../components/team'
|
||||
import { LeadDetail, LeadListItem } from '../../components/market-leads'
|
||||
import { HinweisDetail, HinweisErfassenDialog, HinweisListItem } from '../../components/markt-hinweise'
|
||||
import {
|
||||
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 { DS_ACCENT, DS_BRAND, DS_NEUTRAL } from '../../lib/ds'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [chosenSignalId, setChosenSignalId] = useState<string | null>(null)
|
||||
const [chosenHinweisId, setChosenHinweisId] = useState<string | null>(null)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const [channel, setChannel] = useState<LeadChannelFilter>(LEAD_CHANNEL_ALL)
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const [erfassenOpen, setErfassenOpen] = useState(false)
|
||||
const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL')
|
||||
|
||||
const { data: leads, isLoading: leadsLoading } = useMarketLeads()
|
||||
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
|
||||
const filteredHinweise = useMemo(() => {
|
||||
if (hinweisFilter === 'ALL') return hinweise
|
||||
return hinweise.filter(h => h.visibility === hinweisFilter)
|
||||
}, [hinweise, hinweisFilter])
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { visible, counts } = useMemo(() => {
|
||||
const tally: Record<LeadChannelFilter, number> = {
|
||||
[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
|
||||
// Effekt nachgetragen: der Effekt rief `setState` beim ersten Rendern auf und
|
||||
// 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 resetFilter = useCallback(() => setChannel(LEAD_CHANNEL_ALL), [])
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
|
||||
const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null
|
||||
const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null
|
||||
// Der neue Hinweis soll sofort auffindbar sein — auch wenn gerade nach einem
|
||||
// anderen Kanal gefiltert wird.
|
||||
const handleCreated = useCallback(() => {
|
||||
setErfassenOpen(false)
|
||||
setChannel(LEAD_CHANNEL_ALL)
|
||||
setSelected(null)
|
||||
}, [])
|
||||
|
||||
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).
|
||||
Beispielnutzung: eigene Netzwerkleads mitteilen, aktuelle Leads für
|
||||
einen Ort erfragen. */}
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */}
|
||||
<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>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v: number) => setActiveTab(v)}
|
||||
sx={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'white',
|
||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 },
|
||||
{/* Detailnavigation: pro Kanal die passende Ansicht, in einer Schublade. */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!selected}
|
||||
onClose={closeDetail}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
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" />
|
||||
<Tab icon={<Users size={14} />} iconPosition="start" label="Netzwerk" />
|
||||
</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.KI_SIGNAL && (
|
||||
<LeadDetail lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }} />
|
||||
)}
|
||||
{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
|
||||
open={erfassenOpen}
|
||||
onClose={() => setErfassenOpen(false)}
|
||||
onCreated={(id) => {
|
||||
setChosenHinweisId(id)
|
||||
setErfassenOpen(false)
|
||||
setActiveTab(1)
|
||||
}}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { AgentMeetingRoom } from '../../components/meeting-room'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
import type { PropertyTableFilters } from '../../components/supply'
|
||||
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'
|
||||
|
||||
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
|
||||
@@ -94,9 +95,16 @@ export default function Properties() {
|
||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
// Runde 5, §2.3: Die Startseite scrollt als Ganzes. Der Meetingraum steht
|
||||
// oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
|
||||
// beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
|
||||
// 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%' }}>
|
||||
<AgentMeetingRoom />
|
||||
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
title={MY_PROPERTIES_LABEL}
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<ViewToggle
|
||||
@@ -139,7 +147,7 @@ export default function Properties() {
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: CARD_GRID_COLUMNS, gap: 3, p: 3 }}>
|
||||
{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
|
||||
},
|
||||
}
|
||||
@@ -9,7 +9,6 @@ function pageType(route: string): string {
|
||||
if (route.includes('/supply/match-center')) return 'match-center'
|
||||
if (route.includes('/supply/data-quality')) return 'data-quality'
|
||||
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/compare')) return 'compare'
|
||||
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