From 9ed21e33a7dea77ef2d3ce523ca727539da02d2f Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Thu, 6 Aug 2026 12:44:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(runde-5):=20Startseite=20mit=20Agenten-Mee?= =?UTF-8?q?tingraum,=20sticky=20Chat=20und=20Noras=20Kan=C3=A4le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.tsx | 14 +- src/assets/team/giorgio.svg | 60 ++++ .../assistant/AssistantContextSummary.tsx | 3 +- src/components/auth/DemoRoleSwitcher.tsx | 52 ---- src/components/auth/index.ts | 1 - src/components/layout/AppShellTopBar.tsx | 16 +- src/components/layout/UserMenu.tsx | 11 +- .../layout/__tests__/appShellConfig.test.ts | 28 +- src/components/layout/appShellConfig.ts | 13 +- src/components/market-leads/CrmLeadDetail.tsx | 181 ++++++++++++ .../market-leads/LeadChannelBadge.tsx | 40 +++ .../market-leads/LeadChannelFilterBar.tsx | 112 ++++++++ .../market-leads/MarketLeadPanels.tsx | 50 +--- .../market-leads/UnifiedLeadList.tsx | 140 +++++++++ .../__tests__/UnifiedLeadList.test.tsx | 112 ++++++++ src/components/market-leads/index.ts | 10 +- .../markt-hinweise/MarktHinweisPanels.tsx | 74 +---- src/components/markt-hinweise/index.ts | 2 +- .../meeting-room/AgentMeetingRoom.tsx | 119 ++++++++ src/components/meeting-room/AgentSeat.tsx | 136 +++++++++ .../meeting-room/BaselViewFront.tsx | 102 +++++++ src/components/meeting-room/BaselViewLeft.tsx | 95 ++++++ .../meeting-room/MeetingRoomScene.tsx | 188 ++++++++++++ src/components/meeting-room/index.ts | 5 + .../meeting-room/meetingRoomSeats.ts | 107 +++++++ src/components/onboarding/WelcomeDialog.tsx | 266 +++++++++-------- src/components/supply/DashboardHeader.tsx | 36 --- src/components/supply/DashboardSkeleton.tsx | 67 ----- src/components/supply/DataQualityWidget.tsx | 76 ----- src/components/supply/FutureSignalWidget.tsx | 102 ------- src/components/supply/KpiGrid.tsx | 65 ----- src/components/supply/QuickActionPanel.tsx | 43 --- src/components/supply/ReviewTaskWidget.tsx | 95 ------ src/components/supply/StrongMatchOverview.tsx | 35 --- src/components/supply/index.ts | 10 +- src/components/team/AgentChatStickyBar.tsx | 122 ++++++++ src/components/team/AgentWorkspaceHero.tsx | 75 +++-- src/components/team/agentPhotos.ts | 5 + src/components/team/index.ts | 2 + src/components/team/useAgentChat.ts | 100 +++++++ src/domain/crmLead.ts | 51 ++++ src/domain/unifiedLead.ts | 57 ++++ src/hooks/useCrmLeads.ts | 11 + src/hooks/useSupplyDashboard.ts | 11 - src/hooks/useUnifiedLeads.ts | 92 ++++++ src/lib/agentWorkspaces.ts | 17 ++ src/lib/constants.ts | 30 +- src/lib/ds.ts | 90 ++++++ src/mock-data/crmLeads.ts | 89 ++++++ src/pages/supply/MarketIntelligence.tsx | 272 +++++++----------- src/pages/supply/Properties.tsx | 16 +- src/pages/supply/SupplyDashboard.tsx | 200 ------------- src/provider/ICrmLeadProvider.ts | 6 + src/provider/IDashboardProvider.ts | 18 -- src/provider/MockupCrmLeadProvider.ts | 19 ++ src/services/aiAssistantService.ts | 1 - src/services/crmLeadService.ts | 9 + src/services/dashboardService.ts | 36 --- src/stores/agentChatStore.ts | 59 ++++ 59 files changed, 2545 insertions(+), 1309 deletions(-) create mode 100644 src/assets/team/giorgio.svg delete mode 100644 src/components/auth/DemoRoleSwitcher.tsx create mode 100644 src/components/market-leads/CrmLeadDetail.tsx create mode 100644 src/components/market-leads/LeadChannelBadge.tsx create mode 100644 src/components/market-leads/LeadChannelFilterBar.tsx create mode 100644 src/components/market-leads/UnifiedLeadList.tsx create mode 100644 src/components/market-leads/__tests__/UnifiedLeadList.test.tsx create mode 100644 src/components/meeting-room/AgentMeetingRoom.tsx create mode 100644 src/components/meeting-room/AgentSeat.tsx create mode 100644 src/components/meeting-room/BaselViewFront.tsx create mode 100644 src/components/meeting-room/BaselViewLeft.tsx create mode 100644 src/components/meeting-room/MeetingRoomScene.tsx create mode 100644 src/components/meeting-room/index.ts create mode 100644 src/components/meeting-room/meetingRoomSeats.ts delete mode 100644 src/components/supply/DashboardHeader.tsx delete mode 100644 src/components/supply/DashboardSkeleton.tsx delete mode 100644 src/components/supply/DataQualityWidget.tsx delete mode 100644 src/components/supply/FutureSignalWidget.tsx delete mode 100644 src/components/supply/KpiGrid.tsx delete mode 100644 src/components/supply/QuickActionPanel.tsx delete mode 100644 src/components/supply/ReviewTaskWidget.tsx delete mode 100644 src/components/supply/StrongMatchOverview.tsx create mode 100644 src/components/team/AgentChatStickyBar.tsx create mode 100644 src/components/team/useAgentChat.ts create mode 100644 src/domain/crmLead.ts create mode 100644 src/domain/unifiedLead.ts create mode 100644 src/hooks/useCrmLeads.ts delete mode 100644 src/hooks/useSupplyDashboard.ts create mode 100644 src/hooks/useUnifiedLeads.ts create mode 100644 src/mock-data/crmLeads.ts delete mode 100644 src/pages/supply/SupplyDashboard.tsx create mode 100644 src/provider/ICrmLeadProvider.ts delete mode 100644 src/provider/IDashboardProvider.ts create mode 100644 src/provider/MockupCrmLeadProvider.ts create mode 100644 src/services/crmLeadService.ts delete mode 100644 src/services/dashboardService.ts create mode 100644 src/stores/agentChatStore.ts diff --git a/src/App.tsx b/src/App.tsx index 481fc4f..55ec0a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { - [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 + return } 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 */} }> - } /> + {/* 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. */} + } + /> } /> {/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */} } /> diff --git a/src/assets/team/giorgio.svg b/src/assets/team/giorgio.svg new file mode 100644 index 0000000..6f9c11e --- /dev/null +++ b/src/assets/team/giorgio.svg @@ -0,0 +1,60 @@ + + Giorgio + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/assistant/AssistantContextSummary.tsx b/src/components/assistant/AssistantContextSummary.tsx index 2dfab82..fc6a943 100644 --- a/src/components/assistant/AssistantContextSummary.tsx +++ b/src/components/assistant/AssistantContextSummary.tsx @@ -3,8 +3,7 @@ import type { AssistantContext } from '../../domain/assistant' import { DS_ACCENT, DS_SLATE } from '../../lib/ds' const PAGE_LABELS: Record = { - '/supply/dashboard': 'Übersicht', - '/supply/properties': 'Meine Objekte', + '/supply/properties': 'Startseite', '/supply/match-center': 'Eingehende Bedarfe', '/supply/data-quality': 'Datenpflege', '/supply/future-availability':'Marktchancen', diff --git a/src/components/auth/DemoRoleSwitcher.tsx b/src/components/auth/DemoRoleSwitcher.tsx deleted file mode 100644 index f6fb01e..0000000 --- a/src/components/auth/DemoRoleSwitcher.tsx +++ /dev/null @@ -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 ( - - - Demo-Modus - - - {DEMO_ROLES.map(({ role, label }) => { - const active = currentUser?.role === role - return ( - 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)' }, - }} - /> - ) - })} - - - ) -} diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts index 9588616..954d9de 100644 --- a/src/components/auth/index.ts +++ b/src/components/auth/index.ts @@ -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' diff --git a/src/components/layout/AppShellTopBar.tsx b/src/components/layout/AppShellTopBar.tsx index 27910f2..fe9f84d 100644 --- a/src/components/layout/AppShellTopBar.tsx +++ b/src/components/layout/AppShellTopBar.tsx @@ -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. */} - + {isMobile && ( @@ -49,8 +59,10 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) { )} + + {/* Right side */} - + diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index 1dc45f3..2dd2621 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -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 = { @@ -87,12 +86,10 @@ export function UserMenu() { Produkttour starten - - - {/* Demo role switcher */} - - - + {/* 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. */} diff --git a/src/components/layout/__tests__/appShellConfig.test.ts b/src/components/layout/__tests__/appShellConfig.test.ts index 75e130b..a072e4c 100644 --- a/src/components/layout/__tests__/appShellConfig.test.ts +++ b/src/components/layout/__tests__/appShellConfig.test.ts @@ -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') diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts index 46de82a..5eba829 100644 --- a/src/components/layout/appShellConfig.ts +++ b/src/components/layout/appShellConfig.ts @@ -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 = { 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 = { label: 'Suche', abbreviation: 'SU', icon: Search, - firstPath: '/supply/dashboard', + firstPath: ROUTES.SUPPLY.PROPERTIES, chipColor: '#1a7a4a', navItems: [], }, diff --git a/src/components/market-leads/CrmLeadDetail.tsx b/src/components/market-leads/CrmLeadDetail.tsx new file mode 100644 index 0000000..73af3da --- /dev/null +++ b/src/components/market-leads/CrmLeadDetail.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react' +import { Box, Chip, Divider, Typography } from '@mui/material' +import { Building2, Mail, MapPin, Phone, Ruler, User } from 'lucide-react' +import type { CrmLead } from '../../domain/crmLead' +import { CRM_LEAD_STAGE_LABELS } from '../../domain/crmLead' +import { LeadChannel } from '../../domain/unifiedLead' +import { useProperties } from '../../hooks/useProperties' +import { ObjectDeepLink } from '../team' +import { LeadChannelBadge } from './LeadChannelBadge' +import { areaFitPct } from './marketLeadHelpers' +import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +/** + * Detailansicht eines CRM-Leads (Runde 5, §12). + * + * Bewusst nüchtern: der Lead ist bereits von einem Menschen erfasst, hier + * braucht es keine Konfidenz und keine Signalherleitung — sondern + * Ansprechpartner, Stand, Zuständigkeit und passende Objekte. + * + * Die Herkunft steht oben und nicht im Kleingedruckten: welches System den + * Lead geliefert hat, entscheidet darüber, wie belastbar er ist (§12). + */ +export function CrmLeadDetail({ lead }: { lead: CrmLead }) { + const { data: properties = [] } = useProperties() + + const areaEstimate = lead.areaSqmMax ?? lead.areaSqmMin + const cityKey = lead.locationHint.split(',')[0].trim().toLowerCase() + + /** + * Dieselbe Grosszügigkeit wie bei den KI-Signalen: gesucht wird eine Spanne, + * kein exakter Wert. Ein Objekt darf die Untergrenze um ein Drittel + * unterschreiten und die Obergrenze um die Hälfte überschreiten — darüber + * hinaus lohnt sich das Gespräch nicht mehr. + */ + const matches = useMemo(() => { + const low = (lead.areaSqmMin ?? lead.areaSqmMax ?? 0) * 0.65 + const high = (lead.areaSqmMax ?? lead.areaSqmMin ?? Number.MAX_SAFE_INTEGER) * 1.5 + return properties + .filter(p => { + if (p.assetType !== lead.assetType) return false + const city = (p.location?.city ?? '').toLowerCase() + if (!city || (!city.includes(cityKey) && !cityKey.includes(city))) return false + return areaEstimate ? p.areaSqm >= low && p.areaSqm <= high : true + }) + .map(p => ({ p, fit: areaEstimate ? areaFitPct(p.areaSqm, areaEstimate) : null })) + .sort((a, b) => (b.fit ?? 0) - (a.fit ?? 0)) + .slice(0, 4) + }, [properties, lead.assetType, lead.areaSqmMin, lead.areaSqmMax, areaEstimate, cityKey]) + + const areaStr = areaEstimate + ? lead.areaSqmMin && lead.areaSqmMax && lead.areaSqmMin !== lead.areaSqmMax + ? `${lead.areaSqmMin}–${lead.areaSqmMax} m²` + : `${areaEstimate} m²` + : null + + return ( + + + + + + {lead.crmSystem} · zuletzt kontaktiert {formatDateTime(lead.lastContactAt)} + + + + + {lead.companyName} + + + + + } label={lead.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} /> + {areaStr && } label={areaStr} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />} + + + + + Zuständig: {lead.ownerName} + + + + + + Ansprechpartner + } value={lead.contactPerson} /> + {lead.contactEmail && ( + } value={lead.contactEmail} href={`mailto:${lead.contactEmail}`} /> + )} + {lead.contactPhone && ( + } value={lead.contactPhone} href={`tel:${lead.contactPhone.replace(/\s/g, '')}`} /> + )} + + + {lead.note && ( + <> + + + Notiz aus dem CRM + + {lead.note} + + + + )} + + + + + Passende Objekte im Portfolio ({matches.length}) + {matches.length === 0 ? ( + + Kein Portfolioobjekt erfüllt Ort, Nutzungsart und Flächenbedarf zugleich. + + ) : ( + matches.map(({ p, fit }) => ( + + + + + + {p.location?.city} · {p.areaSqm} m² + + + {fit !== null && ( + + {fit}% Flächenpassung + + )} + + )) + )} + + + + ) +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function ContactLine({ icon, value, href }: { icon: React.ReactNode; value: string; href?: string }) { + return ( + + {icon} + {href ? ( + + {value} + + ) : ( + {value} + )} + + ) +} diff --git a/src/components/market-leads/LeadChannelBadge.tsx b/src/components/market-leads/LeadChannelBadge.tsx new file mode 100644 index 0000000..8bed951 --- /dev/null +++ b/src/components/market-leads/LeadChannelBadge.tsx @@ -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 ( + + ) +}) diff --git a/src/components/market-leads/LeadChannelFilterBar.tsx b/src/components/market-leads/LeadChannelFilterBar.tsx new file mode 100644 index 0000000..2ed3a6c --- /dev/null +++ b/src/components/market-leads/LeadChannelFilterBar.tsx @@ -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 + 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 ( + + + Kanaltyp + + + onChange(LEAD_CHANNEL_ALL)} + /> + + {LEAD_CHANNEL_ORDER.map((channel: LeadChannel) => ( + onChange(channel)} + /> + ))} + + + + {/* Netzwerk-Hinweise entstehen weiterhin hier — die Funktion war an den + entfernten Reiter gebunden und hätte sonst keinen Einstieg mehr. */} + + + ) +} + +function FilterChip({ + label, count, active, activeColor, onClick, +}: { + label: string + count: number + active: boolean + activeColor: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/src/components/market-leads/MarketLeadPanels.tsx b/src/components/market-leads/MarketLeadPanels.tsx index be2f484..19c12d7 100644 --- a/src/components/market-leads/MarketLeadPanels.tsx +++ b/src/components/market-leads/MarketLeadPanels.tsx @@ -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 ( - - - {signal.companyName ?? signal.locationHint} - - {signal.title && ( - - {signal.title} - - )} - - {signal.locationHint} · {signal.timeHorizonMonths} Mo. - - - ) -}) - // ── Contact row ────────────────────────────────────────────────────────────── /** @@ -159,4 +113,4 @@ function SignalPropertyRow({ } -export { LeadListItem, ContactRow, SignalPropertyRow } +export { ContactRow, SignalPropertyRow } diff --git a/src/components/market-leads/UnifiedLeadList.tsx b/src/components/market-leads/UnifiedLeadList.tsx new file mode 100644 index 0000000..59bd19d --- /dev/null +++ b/src/components/market-leads/UnifiedLeadList.tsx @@ -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 ( + 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', + }} + > + + + + + + + {lead.title} + + + {lead.meta} + + + + + {lead.subtitle} + + + + {formatDate(lead.receivedAt)} + + + ) +}) + +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 ( + + {/* Kopfzeile */} + + {COLUMN_LABELS.map(label => ( + + {label} + + ))} + + + {isLoading + ? [1, 2, 3, 4].map(i => ( + + + + + )) + : leads.length === 0 + ? ( + + + + Für diesen Kanaltyp liegen derzeit keine Leads vor. + + {onResetFilter && ( + + )} + + ) + : leads.map(lead => ( + + ))} + + ) +} diff --git a/src/components/market-leads/__tests__/UnifiedLeadList.test.tsx b/src/components/market-leads/__tests__/UnifiedLeadList.test.tsx new file mode 100644 index 0000000..88115c2 --- /dev/null +++ b/src/components/market-leads/__tests__/UnifiedLeadList.test.tsx @@ -0,0 +1,112 @@ +/** + * Property On — gemeinsame Leadliste bei Nora (Runde 5, §12/§13). + * + * Zwei Zusicherungen aus dem Auftrag, die sich sonst niemand ansieht: + * + * 1. Jeder Lead trägt sein Kanalkennzeichen sichtbar in der Zeile. Die Herkunft + * darf nicht bloss aus der Farbe hervorgehen und auch nicht aus der Position + * in der Liste erratbar sein. + * 2. Ein leeres Filterergebnis erklärt sich und bietet den Weg zurück auf + * «Alle». Mit den heutigen Mockdaten ist kein Kanal leer — der Fall wäre in + * der laufenden Anwendung also gar nicht auslösbar und bliebe ungeprüft, bis + * er beim ersten echten Datensatz auftritt. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { screen, cleanup, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '../../../test/teamTestUtils' +import { UnifiedLeadList } from '../UnifiedLeadList' +import { LeadChannel } from '../../../domain/unifiedLead' +import type { UnifiedLead } from '../../../domain/unifiedLead' +import { LEAD_CHANNEL_META } from '../../../lib/ds' + +afterEach(cleanup) + +const KI_LEAD = { + id: 'sig-1', + channel: LeadChannel.KI_SIGNAL, + title: 'Alpbach Advisors AG', + subtitle: 'Erkanntes Nachfragesignal', + meta: 'Zürich · Horizont 6 Mo.', + receivedAt: '2026-08-02T10:00:00.000Z', + signal: { id: 'sig-1' }, + matchingProperties: [], +} as unknown as UnifiedLead + +const NETZ_LEAD = { + id: 'mh-1', + channel: LeadChannel.NETZWERK, + title: 'Fintech Solutions AG', + subtitle: 'Sucht Büro', + meta: 'Zürich Innenstadt · 200–400 m²', + receivedAt: '2026-07-30T10:00:00.000Z', + hinweis: { id: 'mh-1' }, +} as unknown as UnifiedLead + +const CRM_LEAD = { + id: 'crm-1', + channel: LeadChannel.CRM, + title: 'Rheinblick Treuhand AG', + subtitle: 'In Verhandlung — sucht Büro', + meta: 'Basel, Innenstadt · 280–420 m²', + receivedAt: '2026-08-04T10:00:00.000Z', + crmLead: { id: 'crm-1' }, +} as unknown as UnifiedLead + +const ALLE = [CRM_LEAD, KI_LEAD, NETZ_LEAD] + +describe('Gemeinsame Leadliste', () => { + it('zeigt zu jedem Lead die Kanalherkunft als lesbaren Text', () => { + renderWithProviders( + {}} />, + ) + + 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( + {}} />, + ) + 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( + , + ) + 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( + {}} + 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( + {}} />, + ) + expect(screen.getByText(/keine Leads vor/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Alle Kanäle anzeigen' })).toBeNull() + }) +}) diff --git a/src/components/market-leads/index.ts b/src/components/market-leads/index.ts index bf4e17a..48bf4b9 100644 --- a/src/components/market-leads/index.ts +++ b/src/components/market-leads/index.ts @@ -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' diff --git a/src/components/markt-hinweise/MarktHinweisPanels.tsx b/src/components/markt-hinweise/MarktHinweisPanels.tsx index 32c4af7..fabb607 100644 --- a/src/components/markt-hinweise/MarktHinweisPanels.tsx +++ b/src/components/markt-hinweise/MarktHinweisPanels.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo, useState } from 'react' import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material' -import { Building2, CheckCircle2, Copy, MapPin, Ruler, Search } from 'lucide-react' +import { Building2, CheckCircle2, Copy, MapPin, Ruler } from 'lucide-react' import type { MarktHinweis } from '../../domain/marktHinweis' import type { Property } from '../../domain/property' import { useProperties } from '../../hooks/useProperties' @@ -127,76 +127,6 @@ const HinweisPropertyCard = memo(function HinweisPropertyCard({ ) }) -// ── Netzwerk: HinweisListItem ──────────────────────────────────────────────── - -const HinweisListItem = memo(function HinweisListItem({ - hinweis, selected, onClick, -}: { - hinweis: MarktHinweis - selected: boolean - onClick: () => void -}) { - const displayName = !hinweis.isAnonymized && hinweis.companyName - ? hinweis.companyName - : `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}` - - const areaStr = useMemo(() => { - if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { - return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` - } - if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²` - if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²` - return '' - }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) - - const visibilityBadge = useMemo(() => { - if (hinweis.visibility === 'INTERN') { - return - } - if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) { - return - } - return - }, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName]) - - return ( - - - - {hinweis.direction === 'SUCHE' - ? - : - } - - - {displayName} - - {visibilityBadge} - - - {hinweis.locationHint}{areaStr} - - - {hinweis.direction === 'SUCHE' - ? - : - } - - - ) -}) - // ── Netzwerk: HinweisDetail ────────────────────────────────────────────────── function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) { @@ -298,4 +228,4 @@ function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) { } -export { HinweisPropertyCard, HinweisListItem, HinweisDetail } +export { HinweisPropertyCard, HinweisDetail } diff --git a/src/components/markt-hinweise/index.ts b/src/components/markt-hinweise/index.ts index c1f10a9..60f494b 100644 --- a/src/components/markt-hinweise/index.ts +++ b/src/components/markt-hinweise/index.ts @@ -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' diff --git a/src/components/meeting-room/AgentMeetingRoom.tsx b/src/components/meeting-room/AgentMeetingRoom.tsx new file mode 100644 index 0000000..3c2fd94 --- /dev/null +++ b/src/components/meeting-room/AgentMeetingRoom.tsx @@ -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 ( + + {/* Die Aussicht nimmt den Platz, den die Kacheln nicht brauchen — sonst + klafft auf hohen Schirmen eine leere Wand zwischen beiden. */} + + + + + + + {MEETING_ROOM_SEATS.map(seat => ( + 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 }, + }} + > + + + + {seat.name} + + + {seat.role} + + + + ))} + + + ) + } + + return ( + + + {MEETING_ROOM_SEATS.map(seat => ( + + ))} + + ) +} diff --git a/src/components/meeting-room/AgentSeat.tsx b/src/components/meeting-room/AgentSeat.tsx new file mode 100644 index 0000000..ab3590e --- /dev/null +++ b/src/components/meeting-room/AgentSeat.tsx @@ -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 `
`: 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 ( + 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). + */} + + + {seat.name} + + + {seat.role} + + + + {/* Stuhllehne hinter dem Kopf — am Kopfende sitzt Giorgio frontal. */} + + + + + ) +}) diff --git a/src/components/meeting-room/BaselViewFront.tsx b/src/components/meeting-room/BaselViewFront.tsx new file mode 100644 index 0000000..6e83bb1 --- /dev/null +++ b/src/components/meeting-room/BaselViewFront.tsx @@ -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 ( + + ) +}) diff --git a/src/components/meeting-room/BaselViewLeft.tsx b/src/components/meeting-room/BaselViewLeft.tsx new file mode 100644 index 0000000..80cb819 --- /dev/null +++ b/src/components/meeting-room/BaselViewLeft.tsx @@ -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 ( + + ) +}) diff --git a/src/components/meeting-room/MeetingRoomScene.tsx b/src/components/meeting-room/MeetingRoomScene.tsx new file mode 100644 index 0000000..34c8d30 --- /dev/null +++ b/src/components/meeting-room/MeetingRoomScene.tsx @@ -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 ( + + {/* Decke */} + + + {/* Boden */} + + + {/* ── Rechte Seite: geschlossene Wand ── */} + + {/* Sockelleiste — trennt Wand und Boden sichtbar voneinander */} + + + {/* ── Linke Seite: boden- bis deckenhohe Glasfront, Blick rheinabwärts ── */} + + {/* + 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. + */} + + + + + + {/* Senkrechte Pfosten */} + {[9, 17.5, 25.5].map(x => ( + + ))} + {/* Waagrechter Kämpfer — läuft mit der Perspektive zum Fluchtpunkt */} + + {/* Deckenanschluss und Bodenschiene der Front */} + + + {/* Streiflicht auf dem Glas */} + + + + {/* ── Gegenüberliegende Fensterfront: Rhein, Altstadt, Münster ── */} + + + + {/* Zwei Pfosten teilen die Front in drei Felder, dazu ein Kämpfer */} + {[33.3, 66.6].map(x => ( + + ))} + + + + + {/* ── Besprechungstisch: vom Betrachter zur Fensterfront verlaufend ── */} + {/* Schlagschatten auf dem Boden — ohne ihn schwebt die Platte über nichts */} + + + {/* Hintere Tischkante — dünne dunkle Linie, gibt der Platte Dicke */} + + {/* Heller Mittelstreifen — führt den Blick zur Fensterfront */} + + + ) +}) diff --git a/src/components/meeting-room/index.ts b/src/components/meeting-room/index.ts new file mode 100644 index 0000000..8157fe5 --- /dev/null +++ b/src/components/meeting-room/index.ts @@ -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' diff --git a/src/components/meeting-room/meetingRoomSeats.ts b/src/components/meeting-room/meetingRoomSeats.ts new file mode 100644 index 0000000..6cd45ad --- /dev/null +++ b/src/components/meeting-room/meetingRoomSeats.ts @@ -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 + +/** + * 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 = { + 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 diff --git a/src/components/onboarding/WelcomeDialog.tsx b/src/components/onboarding/WelcomeDialog.tsx index 8f28a3a..200de47 100644 --- a/src/components/onboarding/WelcomeDialog.tsx +++ b/src/components/onboarding/WelcomeDialog.tsx @@ -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 ( - - {icon} + + {icon} + + {title} + + + {lead} + + {children && {children}} + + ) +} + +function FeatureRow({ icon, color, text }: { icon: ReactNode; color: string; text: string }) { + return ( + + {icon} {text} ) } -function Slide0() { +function SlideStartseite() { return ( - - - - - - Willkommen bei Property Match - - - Die KI-gestützte Plattform für gewerbliche Immobilien - - - } - color="#1a7a4a" - text="Verifizierte Portfolio-Objekte" - /> - } - color="#1a7a4a" - text="Externer Markt & Inserate" - /> - } - color="#7c3aed" - text="Future Availability Intelligence" - /> - - + } + title={`Ihre ${HOME_NAV_LABEL}`} + lead="Der Einstieg ist der Besprechungsraum Ihrer digitalen Mitarbeitenden — darunter folgt Ihr Objektbestand." + > + } + color={DS_BRAND.main} + text="Ein Klick auf einen Agenten am Tisch öffnet direkt dessen Arbeitsbereich." + /> + } + color={DS_ACCENT.success.main} + text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`} + /> + } + color={DS_ACCENT.warning.main} + text="Über «Filter & Übersicht» schränken Sie den Bestand nach Nutzungsart, Status und Verfügbarkeit ein." + /> + ) } -function Slide1() { +function SlideAgenten() { return ( - - - + } + title={MY_AGENTS_LABEL} + lead="Fünf digitale Mitarbeitende mit festem Aufgabenprofil — jeder mit eigenem Arbeitsbereich." + > + + {AGENT_WORKSPACES.map(agent => ( + + + + + {agent.name} + + + {agent.role} + + + + ))} - - Bedarf beschreiben — KI findet Matches - - - 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. - - - } - color="#d97706" - text="Spracheingabe auf Deutsch" - /> - } - color="#7c3aed" - text="KI analysiert und gewichtet automatisch" - /> - - + } + color={DS_ACCENT.violet.main} + text="Giorgio am Kopfende des Tisches führt in die Personalverwaltung: Aufgaben, Kanäle & Systeme, Bearbeitungsverlauf." + /> + ) } -function Slide2() { +function SlideChat() { return ( - - - - - - Future Availability Intelligence - - - Frühzeitige Marktintelligenz zu potenziell verfügbaren Flächen — verifizierte - Vertragsenden und erkannte Marktsignale, bevor sie öffentlich werden. - - - } - color="#d97706" - text="Presse & Stelleninserate" - /> - } - color="#1a7a4a" - text="Baubewilligungen & Berichte" - /> - - + } + title="Jeder Agent ist ansprechbar" + lead="Auf jeder Agentenseite steht oben ein Eingabefeld — Sie schreiben dem Agenten wie einem Menschen." + > + } + color={DS_ACCENT.success.main} + text="Beim Scrollen rutscht der Chat kompakt in die oberste Leiste und bleibt erreichbar." + /> + } + color={DS_ACCENT.warning.main} + text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter." + /> + ) } -const SLIDES = [, , ] +function SlideLeads() { + return ( + } + title="Leads aus drei Kanälen" + lead="Nora führt Marktchancen aus allen Quellen in einer Liste zusammen — mit sichtbarer Herkunft." + > + } + color={LEAD_CHANNEL_META.KI_SIGNAL.color} + text="KI Signal — aus Presse, Baugesuchen und Stelleninseraten erkannt." + /> + } + color={LEAD_CHANNEL_META.NETZWERK.color} + text="Netzwerk — persönlich erfasste Hinweise aus Gesprächen und Anlässen." + /> + } + color={LEAD_CHANNEL_META.CRM.color} + text="CRM — bestehende Interessenten aus dem angebundenen Vertriebssystem." + /> + } + color={DS_TEXT.secondary} + text="Der Filter «Kanaltyp» blendet einzelne Quellen aus, ohne die Liste zu wechseln." + /> + + ) +} + +const SLIDES = [, , , ] 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 ( - + {SLIDES[activeStep]} setActiveStep((s) => s - 1)} + onClick={() => setActiveStep(s => s - 1)} disabled={activeStep === 0} startIcon={} > @@ -166,16 +210,12 @@ export function WelcomeDialog() { } nextButton={ - activeStep === 2 ? ( + activeStep === lastStep ? ( ) : ( - ) diff --git a/src/components/supply/DashboardHeader.tsx b/src/components/supply/DashboardHeader.tsx deleted file mode 100644 index e48819c..0000000 --- a/src/components/supply/DashboardHeader.tsx +++ /dev/null @@ -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 ( - - - - {orgName} - - - - {lastUpdated && ( - - Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)} - - )} - - ) -} diff --git a/src/components/supply/DashboardSkeleton.tsx b/src/components/supply/DashboardSkeleton.tsx deleted file mode 100644 index b9d45a3..0000000 --- a/src/components/supply/DashboardSkeleton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Box, Card, CardContent, Skeleton } from '@mui/material' - -function SkeletonCard() { - return ( - - - - - - - ) -} - -function SkeletonLargeCard() { - return ( - - - - - - - - ) -} - -export function DashboardSkeleton() { - return ( - - {/* Header skeleton */} - - - - - - {/* KPI grid skeleton */} - - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - - {/* Row 2 */} - - - - - - {/* Row 3 */} - - - - - - {/* Quick actions skeleton */} - - - - - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - - - - ) -} diff --git a/src/components/supply/DataQualityWidget.tsx b/src/components/supply/DataQualityWidget.tsx deleted file mode 100644 index a70d12b..0000000 --- a/src/components/supply/DataQualityWidget.tsx +++ /dev/null @@ -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 ( - - - - - Datenqualität - - - - - - - - Ø Score - - - {summary.avgScore}% - - - - - - - - 0 ? 'error.main' : 'text.primary' }}> - {summary.critical} - - - Kritische Objekte - - - - - {summary.propertiesWithMissingCritical} - - - Fehlende Pflichtfelder - - - - - {summary.topMissingFields.length > 0 && ( - - - Häufig fehlende Felder: - - - {summary.topMissingFields.map(field => ( - - ))} - - - )} - - - ) -} diff --git a/src/components/supply/FutureSignalWidget.tsx b/src/components/supply/FutureSignalWidget.tsx deleted file mode 100644 index 3cf9daa..0000000 --- a/src/components/supply/FutureSignalWidget.tsx +++ /dev/null @@ -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 ( - - - {value} - - - {label} - - - ) -} - -export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) { - const navigate = useNavigate() - const total = summary.total || 1 - const dist = summary.timeHorizonDistribution - - return ( - - - - - Marktsignale - - navigate('/supply/future-availability')} - > - Alle anzeigen - - - - - - - - - - - - - {/* Time horizon distribution */} - - Zeithorizont-Verteilung - - - {[ - { label: '0–6 Monate', count: dist.short }, - { label: '6–12 Monate', count: dist.medium }, - { label: '12–24 Monate', count: dist.long }, - ].map(({ label, count }) => ( - - - {label} - {count} - - - - ))} - - - {summary.restricted > 0 && ( - <> - - - {summary.restricted} vertrauliche Signale — nur für berechtigte Nutzer sichtbar. - - - )} - - - - Marktsignale basieren auf AI-Analyse öffentlicher und interner Daten. Es handelt - sich um probabilistische Einschätzungen, keine bestätigten Objekte. - - - - ) -} diff --git a/src/components/supply/KpiGrid.tsx b/src/components/supply/KpiGrid.tsx deleted file mode 100644 index f5cd145..0000000 --- a/src/components/supply/KpiGrid.tsx +++ /dev/null @@ -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 ( - - {cards.map(card => ( - - ))} - - ) -} diff --git a/src/components/supply/QuickActionPanel.tsx b/src/components/supply/QuickActionPanel.tsx deleted file mode 100644 index f63cdc0..0000000 --- a/src/components/supply/QuickActionPanel.tsx +++ /dev/null @@ -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 ( - - - - Schnellzugriff - - - {ACTIONS.map(action => ( - - ))} - - - - ) -} diff --git a/src/components/supply/ReviewTaskWidget.tsx b/src/components/supply/ReviewTaskWidget.tsx deleted file mode 100644 index 7a66e19..0000000 --- a/src/components/supply/ReviewTaskWidget.tsx +++ /dev/null @@ -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 = { - HIGH: 'Hoch', - MEDIUM: 'Mittel', - LOW: 'Niedrig', -} - -const STATUS_LABELS: Record = { - 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 = { 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 ( - - - - - Review Queue - - - - - {sorted.length === 0 ? ( - - Keine offenen Aufgaben. - - ) : ( - - {sorted.map(task => ( - - - - {task.title} - - - - ))} - - )} - - - ) -} diff --git a/src/components/supply/StrongMatchOverview.tsx b/src/components/supply/StrongMatchOverview.tsx deleted file mode 100644 index af529ff..0000000 --- a/src/components/supply/StrongMatchOverview.tsx +++ /dev/null @@ -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 ( - - - - - Starke Matches - - - - - {matches.length === 0 ? ( - - Keine starken Matches vorhanden. - - ) : ( - matches.slice(0, 5).map(m => ( - - )) - )} - - - ) -} diff --git a/src/components/supply/index.ts b/src/components/supply/index.ts index 25b778b..6152bf7 100644 --- a/src/components/supply/index.ts +++ b/src/components/supply/index.ts @@ -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' diff --git a/src/components/team/AgentChatStickyBar.tsx b/src/components/team/AgentChatStickyBar.tsx new file mode 100644 index 0000000..8cd76d3 --- /dev/null +++ b/src/components/team/AgentChatStickyBar.tsx @@ -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 +} + +function StickyChat() { + const { agent, draft, inputRef, setDraft, send, handleKeyDown, onFocus, onBlur } = useAgentChat() + if (!agent) return null + + return ( + + + + + {agent.name} + + + {/* + 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. + */} + + {AGENT_CHAT_PROMPT_SHORT} + + + 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: ( + + + + ), + }, + }} + /> + + ) +} diff --git a/src/components/team/AgentWorkspaceHero.tsx b/src/components/team/AgentWorkspaceHero.tsx index c247320..f9f1f2d 100644 --- a/src/components/team/AgentWorkspaceHero.tsx +++ b/src/components/team/AgentWorkspaceHero.tsx @@ -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(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) { - 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 ( - + 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: ( diff --git a/src/components/team/agentPhotos.ts b/src/components/team/agentPhotos.ts index ce05b3c..d057ff3 100644 --- a/src/components/team/agentPhotos.ts +++ b/src/components/team/agentPhotos.ts @@ -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 = { bruno, @@ -86,4 +90,5 @@ export const AGENT_PHOTOS: Record = { ida, jana, zeno, + giorgio, } diff --git a/src/components/team/index.ts b/src/components/team/index.ts index 32bedab..b05eeac 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -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 diff --git a/src/components/team/useAgentChat.ts b/src/components/team/useAgentChat.ts new file mode 100644 index 0000000..d712b54 --- /dev/null +++ b/src/components/team/useAgentChat.ts @@ -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(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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } + }, + [send], + ) + + const onBlur = useCallback( + (e: FocusEvent) => { + // 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, + } +} diff --git a/src/domain/crmLead.ts b/src/domain/crmLead.ts new file mode 100644 index 0000000..665449b --- /dev/null +++ b/src/domain/crmLead.ts @@ -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.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 +} diff --git a/src/domain/unifiedLead.ts b/src/domain/unifiedLead.ts new file mode 100644 index 0000000..3db9354 --- /dev/null +++ b/src/domain/unifiedLead.ts @@ -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 }) diff --git a/src/hooks/useCrmLeads.ts b/src/hooks/useCrmLeads.ts new file mode 100644 index 0000000..4ebec4f --- /dev/null +++ b/src/hooks/useCrmLeads.ts @@ -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, + }) +} diff --git a/src/hooks/useSupplyDashboard.ts b/src/hooks/useSupplyDashboard.ts deleted file mode 100644 index 9d10620..0000000 --- a/src/hooks/useSupplyDashboard.ts +++ /dev/null @@ -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({ - queryKey: ['supply', 'dashboard'], - queryFn: () => dashboardService.getDashboardData(), - staleTime: 30_000, - }) -} diff --git a/src/hooks/useUnifiedLeads.ts b/src/hooks/useUnifiedLeads.ts new file mode 100644 index 0000000..a32a4d4 --- /dev/null +++ b/src/hooks/useUnifiedLeads.ts @@ -0,0 +1,92 @@ +import { useMemo } from 'react' +import { useMarketLeads } from './useMarketLeads' +import { useMarktHinweise } from './useMarktHinweise' +import { useCrmLeads } from './useCrmLeads' +import { LeadChannel } from '../domain/unifiedLead' +import type { UnifiedLead } from '../domain/unifiedLead' +import type { MarktHinweis } from '../domain/marktHinweis' +import type { CrmLead } from '../domain/crmLead' +import { CRM_LEAD_STAGE_LABELS } from '../domain/crmLead' +import { ASSET_TYPE_LABELS } from '../lib/constants' +import type { MarketLead } from './useMarketLeads' + +/** + * Zusammengeführte Leadliste aller drei Kanäle (Runde 5, §12). + * + * Die drei Quellen bleiben getrennt erfasst — KI-Signale entstehen aus + * Marktbeobachtung, Netzwerk-Hinweise aus Gesprächen, CRM-Leads aus dem + * Vertriebssystem. Zusammengelegt werden sie erst hier, für die Anzeige, und + * jeder Eintrag behält seine Kanalherkunft im Feld `channel`. + * + * Ein gemeinsamer Provider für alle drei wäre der falsche Weg: die Quellen + * haben unterschiedliche Lebenszyklen und werden später von verschiedenen + * Systemen bedient. + */ + +function areaText(min?: number, max?: number): string | null { + if (min != null && max != null && min !== max) return `${min}–${max} m²` + if (max != null) return `${max} m²` + if (min != null) return `${min} m²` + return null +} + +function fromMarketLead(lead: MarketLead): UnifiedLead { + const { signal } = lead + return { + id: signal.id, + channel: LeadChannel.KI_SIGNAL, + title: signal.companyName ?? signal.locationHint, + subtitle: signal.title ?? 'Erkanntes Nachfragesignal', + meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`, + receivedAt: signal.updatedAt || signal.createdAt, + signal, + matchingProperties: lead.matchingProperties, + } +} + +function fromHinweis(hinweis: MarktHinweis): UnifiedLead { + const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonymer Hinweis' + const area = areaText(hinweis.areaSqmMin, hinweis.areaSqmMax) + const assetLabel = ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType + return { + id: hinweis.id, + channel: LeadChannel.NETZWERK, + title: displayName, + subtitle: hinweis.direction === 'SUCHE' ? `Sucht ${assetLabel}` : `Fläche wird verfügbar — ${assetLabel}`, + meta: [hinweis.locationHint, area, hinweis.createdBy].filter(Boolean).join(' · '), + receivedAt: hinweis.createdAt, + hinweis, + } +} + +function fromCrmLead(crmLead: CrmLead): UnifiedLead { + const area = areaText(crmLead.areaSqmMin, crmLead.areaSqmMax) + const assetLabel = ASSET_TYPE_LABELS[crmLead.assetType] ?? crmLead.assetType + return { + id: crmLead.id, + channel: LeadChannel.CRM, + title: crmLead.companyName, + subtitle: `${CRM_LEAD_STAGE_LABELS[crmLead.stage]} — sucht ${assetLabel}`, + meta: [crmLead.locationHint, area, crmLead.ownerName].filter(Boolean).join(' · '), + receivedAt: crmLead.lastContactAt, + crmLead, + } +} + +export function useUnifiedLeads(): { data: UnifiedLead[]; isLoading: boolean } { + const { data: marketLeads, isLoading: marketLoading } = useMarketLeads() + const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise() + const { data: crmLeads = [], isLoading: crmLoading } = useCrmLeads() + + const data = useMemo( + () => + [ + ...marketLeads.map(fromMarketLead), + ...hinweise.map(fromHinweis), + ...crmLeads.map(fromCrmLead), + ].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)), + [marketLeads, hinweise, crmLeads], + ) + + return { data, isLoading: marketLoading || hinweiseLoading || crmLoading } +} diff --git a/src/lib/agentWorkspaces.ts b/src/lib/agentWorkspaces.ts index 886529c..cb4a789 100644 --- a/src/lib/agentWorkspaces.ts +++ b/src/lib/agentWorkspaces.ts @@ -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, +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e211e60..d1d5991 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { /** 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 = { diff --git a/src/lib/ds.ts b/src/lib/ds.ts index a6aed1f..5433610 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -291,3 +291,93 @@ export const RESULT_TYPE_META: Record = { + 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)' }, +} diff --git a/src/mock-data/crmLeads.ts b/src/mock-data/crmLeads.ts new file mode 100644 index 0000000..7978c0e --- /dev/null +++ b/src/mock-data/crmLeads.ts @@ -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', + }, +] diff --git a/src/pages/supply/MarketIntelligence.tsx b/src/pages/supply/MarketIntelligence.tsx index 19e5ae9..775341c 100644 --- a/src/pages/supply/MarketIntelligence.tsx +++ b/src/pages/supply/MarketIntelligence.tsx @@ -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(null) - const [chosenHinweisId, setChosenHinweisId] = useState(null) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const [channel, setChannel] = useState(LEAD_CHANNEL_ALL) + const [selected, setSelected] = useState(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 = { + [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 ( - + {/* Chat-Einstieg statt Seitentitel und Beschreibung (Runde 4, §7.1). - Beispielnutzung: eigene Netzwerkleads mitteilen, aktuelle Leads für - einen Ort erfragen. */} - - + Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */} + + + setErfassenOpen(true)} + /> + + + + {visible.length === leads.length + ? `${leads.length} Leads aus ${LEAD_CHANNEL_ORDER.length} Kanälen` + : `${visible.length} von ${leads.length} Leads`} + + + - {/* Tabs */} - 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. */} + - } iconPosition="start" label="KI-Signale" /> - } iconPosition="start" label="Netzwerk" /> - + {selected?.channel === LeadChannel.KI_SIGNAL && ( + + )} + {selected?.channel === LeadChannel.NETZWERK && } + {selected?.channel === LeadChannel.CRM && } + - {/* Tab 0: KI-Signale */} - {activeTab === 0 && ( - - {/* Left pane */} - - - Erkannte Signale - - - - {leadsLoading - ? [1, 2, 3, 4].map(i => ( - - - - - - )) - : leads.map(lead => ( - setChosenSignalId(lead.signal.id)} - /> - ))} - - - - {/* Right pane */} - - {selectedLead ? ( - - ) : ( - - Signal aus der Liste auswählen - - )} - - - )} - - {/* Tab 1: Netzwerk */} - {activeTab === 1 && ( - - {/* Left pane */} - - - - Netzwerk-Hinweise - - - - {/* Filter chips */} - - {(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => ( - setHinweisFilter(f)} - sx={{ - height: 22, fontSize: '0.68rem', cursor: 'pointer', - bgcolor: hinweisFilter === f ? '#152642' : '#f1f5f9', - color: hinweisFilter === f ? 'white' : '#475569', - }} - /> - ))} - - - - {/* List */} - - {hinweiseLoading - ? [1, 2, 3].map(i => ( - - - - - )) - : filteredHinweise.map(h => ( - setChosenHinweisId(h.id)} - /> - )) - } - - - - {/* Right pane */} - - {selectedHinweis ? ( - - ) : ( - - Hinweis aus der Liste auswählen - - )} - - - )} - - {/* Dialog */} setErfassenOpen(false)} - onCreated={(id) => { - setChosenHinweisId(id) - setErfassenOpen(false) - setActiveTab(1) - }} + onCreated={handleCreated} /> ) diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx index 7c9653b..0b5ef2e 100644 --- a/src/pages/supply/Properties.tsx +++ b/src/pages/supply/Properties.tsx @@ -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 ( - + // 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. + + + - + {view === 'grid' ? ( {filtered.map(p => ( diff --git a/src/pages/supply/SupplyDashboard.tsx b/src/pages/supply/SupplyDashboard.tsx deleted file mode 100644 index e331e28..0000000 --- a/src/pages/supply/SupplyDashboard.tsx +++ /dev/null @@ -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 - - if (isError) { - return ( - - Dashboard konnte nicht geladen werden - Der Service ist vorübergehend nicht verfügbar. - - - ) - } - - if (!data || data.totalProperties === 0) { - return ( - - Noch keine Objekte vorhanden - - Fügen Sie Ihr erstes Objekt hinzu — wir matchen es sofort mit aktiven Suchanfragen. - - - - ) - } - - const strongMatchCount = data.strongMatchCount ?? 0 - const topMatches = data.strongMatches ?? [] - const futureSignalCount = data.futureSignals?.total ?? 0 - - return ( - - - - {/* ── Hero: Nachfrage-Intelligence ── */} - - - {/* KPI strip */} - - {/* Starke Matches */} - 0 ? DS_SURFACE.success.bg : 'white' }}> - - 0 ? DS_TEXT.success : DS_TEXT.muted} /> - 0 ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}> - Starke Match-Anfragen - - - 0 ? DS_TEXT.success : DS_TEXT.disabled, lineHeight: 1 }}> - {strongMatchCount} - - - qualifizierte Interessenten ≥ 80% - - - - {/* Aktive Objekte */} - navigate('/supply/properties')}> - - - - Aktive Objekte - - - - {data.activeProperties} - - / {data.totalProperties} - - - verfügbar · im Bestand - - - {/* Zukunftssignale */} - 0 ? '#faf5ff' : 'white' }}> - - 0 ? '#7c3aed' : DS_TEXT.muted} /> - 0 ? '#7c3aed' : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}> - Zukunftssignale - - - 0 ? '#7c3aed' : DS_TEXT.disabled, lineHeight: 1 }}> - {futureSignalCount} - - - vorqualifizierte Nachfrage-Signale - - - - - {/* ── Top matches list ── */} - {topMatches.length > 0 && ( - <> - - - - Stärkste Interessenten - - - - {topMatches.slice(0, 4).map((m, i) => ( - 0 ? `1px solid ${DS_BORDER.muted}` : undefined, - cursor: 'pointer', - '&:hover': { bgcolor: DS_BG.subtle }, - }} - onClick={() => navigate('/supply/match-center')} - > - = 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', - }}> - = 80 ? DS_TEXT.success : DS_TEXT.warning }}> - {m.matchScore}% - - - - {m.propertyTitle} - {m.topReason} - - - - - ))} - - - - - - - )} - - - {/* ── Datenpflege (sekundär, collapsed) ── */} - {(data.dataQuality?.critical ?? 0) > 0 && ( - - - - - {data.dataQuality!.critical} Objekte mit Datenlücken - - - Vollständige Daten verbessern die Match-Qualität erheblich. - - - - - - )} - - {isReviewer && data.reviewTasks !== null && ( - navigate('/ops/review-queue')} - /> - )} - - ) -} diff --git a/src/provider/ICrmLeadProvider.ts b/src/provider/ICrmLeadProvider.ts new file mode 100644 index 0000000..a08808c --- /dev/null +++ b/src/provider/ICrmLeadProvider.ts @@ -0,0 +1,6 @@ +import type { CrmLead } from '../domain/crmLead' + +export interface ICrmLeadProvider { + getAll(): Promise + getById(id: string): Promise +} diff --git a/src/provider/IDashboardProvider.ts b/src/provider/IDashboardProvider.ts deleted file mode 100644 index a46d2fc..0000000 --- a/src/provider/IDashboardProvider.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface DashboardStats { - totalProperties: number - verifiedProperties: number - verifiedPortfolioProperties: number - futureSignalProperties: number - totalMatches: number - pendingReviews: number - approvedMatches: number - activeNeeds: number - totalSignals: number - verifiedSignals: number - averageMatchScore: number - highConfidenceMatches: number -} - -export interface IDashboardProvider { - getStats(organizationId?: string): Promise -} diff --git a/src/provider/MockupCrmLeadProvider.ts b/src/provider/MockupCrmLeadProvider.ts new file mode 100644 index 0000000..ae625dc --- /dev/null +++ b/src/provider/MockupCrmLeadProvider.ts @@ -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 + }, +} diff --git a/src/services/aiAssistantService.ts b/src/services/aiAssistantService.ts index dea3936..9dc0286 100644 --- a/src/services/aiAssistantService.ts +++ b/src/services/aiAssistantService.ts @@ -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' diff --git a/src/services/crmLeadService.ts b/src/services/crmLeadService.ts new file mode 100644 index 0000000..4c6baf4 --- /dev/null +++ b/src/services/crmLeadService.ts @@ -0,0 +1,9 @@ +import { MockupCrmLeadProvider } from '../provider/MockupCrmLeadProvider' +import type { CrmLead } from '../domain/crmLead' + +const provider = MockupCrmLeadProvider + +export const crmLeadService = { + getAll: (): Promise => provider.getAll(), + getById: (id: string): Promise => provider.getById(id), +} diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts deleted file mode 100644 index d51df6a..0000000 --- a/src/services/dashboardService.ts +++ /dev/null @@ -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 { - 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(), - } - }, -} diff --git a/src/stores/agentChatStore.ts b/src/stores/agentChatStore.ts new file mode 100644 index 0000000..eaa503f --- /dev/null +++ b/src/stores/agentChatStore.ts @@ -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((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 }), +}))