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:
Benjamin Sutter
2026-08-06 12:44:22 +02:00
parent d200d4e930
commit 9ed21e33a7
59 changed files with 2545 additions and 1309 deletions
+10 -4
View File
@@ -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 />} />
+60
View File
@@ -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',
-52
View File
@@ -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>
)
}
-1
View File
@@ -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'
+14 -2
View File
@@ -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
View File
@@ -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')
+7 -6
View File
@@ -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}`
: `${areaEstimate}`
: 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 · 200400 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 · 280420 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()
})
})
+8 -2
View File
@@ -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}`
}
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax}`
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin}`
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 -1
View File
@@ -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>
)
}
+136
View File
@@ -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>
)
})
+5
View File
@@ -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
+140 -100
View File
@@ -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>
)
-36
View File
@@ -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: '06 Monate', count: dist.short },
{ label: '612 Monate', count: dist.medium },
{ label: '1224 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>
)
}
-65
View File
@@ -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>
)
}
+2 -8
View File
@@ -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'
+122
View File
@@ -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>
)
}
+50 -25
View File
@@ -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 }}
>
+5
View File
@@ -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,
}
+2
View File
@@ -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
+100
View File
@@ -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,
}
}
+51
View File
@@ -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
}
+57
View File
@@ -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 })
+11
View File
@@ -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,
})
}
-11
View File
@@ -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,
})
}
+92
View File
@@ -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}`
if (max != null) return `${max}`
if (min != null) return `${min}`
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 }
}
+17
View File
@@ -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
View File
@@ -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 = {
+90
View File
@@ -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)' },
}
+89
View File
@@ -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',
},
]
+99 -169
View File
@@ -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>
)
+12 -4
View File
@@ -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 => (
-200
View File
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { CrmLead } from '../domain/crmLead'
export interface ICrmLeadProvider {
getAll(): Promise<CrmLead[]>
getById(id: string): Promise<CrmLead | null>
}
-18
View File
@@ -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>
}
+19
View File
@@ -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
},
}
-1
View File
@@ -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'
+9
View File
@@ -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),
}
-36
View File
@@ -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(),
}
},
}
+59
View File
@@ -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 }),
}))