From 0bacd188d6d276af143bbc86ee1ed1d2a3a9f621 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 01:00:38 +0200 Subject: [PATCH] refactor: split AppShell, match-detail panels, extract useCompareData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppShell.tsx: 627→116 lines - appShellConfig.ts: NavItem/WorkspaceConfig types, WORKSPACE_CONFIG, nav helpers - AppShellSidebar.tsx: Sidebar component with visual constants - AppShellTopBar.tsx: TopBar component Match-detail panels: - ScoreBreakdownPanel: 377→303 lines (scoreBreakdownConstants.ts + CriterionRow.tsx extracted) - LocationIntelligencePanel: 383→333 lines (SoftFactorBar.tsx extracted) - FutureAvailabilityContextPanel: 386→351 lines (futureAvailabilityConstants.tsx extracted) Compare.tsx: 485→441 lines - useCompareData hook: all queries and derived state extracted to hooks/useCompareData.ts Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/AppShell.tsx | 525 +----------------- src/components/layout/AppShellSidebar.tsx | 313 +++++++++++ src/components/layout/AppShellTopBar.tsx | 92 +++ src/components/layout/appShellConfig.ts | 114 ++++ src/components/match-detail/CriterionRow.tsx | 56 ++ .../FutureAvailabilityContextPanel.tsx | 37 +- .../LocationIntelligencePanel.tsx | 54 +- .../match-detail/ScoreBreakdownPanel.tsx | 80 +-- src/components/match-detail/SoftFactorBar.tsx | 50 ++ .../futureAvailabilityConstants.tsx | 31 ++ .../match-detail/scoreBreakdownConstants.ts | 28 + src/hooks/useCompareData.ts | 81 +++ src/pages/demand/Compare.tsx | 72 +-- 13 files changed, 792 insertions(+), 741 deletions(-) create mode 100644 src/components/layout/AppShellSidebar.tsx create mode 100644 src/components/layout/AppShellTopBar.tsx create mode 100644 src/components/layout/appShellConfig.ts create mode 100644 src/components/match-detail/CriterionRow.tsx create mode 100644 src/components/match-detail/SoftFactorBar.tsx create mode 100644 src/components/match-detail/futureAvailabilityConstants.tsx create mode 100644 src/components/match-detail/scoreBreakdownConstants.ts create mode 100644 src/hooks/useCompareData.ts diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 18c8cca..4bd0a74 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,527 +1,16 @@ import { useEffect, useState } from 'react' -import { Outlet, NavLink, useNavigate, useLocation } from 'react-router' -import { WelcomeDialog } from '../onboarding/WelcomeDialog' +import { Outlet, useNavigate, useLocation } from 'react-router' +import { Box, Drawer, useTheme, useMediaQuery } from '@mui/material' import { useLayoutStore } from '../../stores/layoutStore' import { useSessionStore } from '../../stores/sessionStore' -import { WorkspaceType } from '../../domain/enums' -import { - Box, - Drawer, - Typography, - Avatar, - IconButton, - Tooltip, - Chip, - Button, - useTheme, - useMediaQuery, -} from '@mui/material' -import { - LayoutDashboard, - Building2, - Target, - CheckSquare, - Search, - List, - Columns2, - Bookmark, - ClipboardList, - Activity, - Shield, - ChevronLeft, - ChevronRight, - Sparkles, - Clock, - Radar, - ServerCog, - GitBranch, - MessageSquare, - Menu, - Kanban, - BellRing, - Plus, -} from 'lucide-react' -import type { LucideIcon } from 'lucide-react' -import { OrganizationContextBadge } from './OrganizationContextBadge' -import { UserMenu } from './UserMenu' -import { NotificationButton } from './NotificationButton' +import { WelcomeDialog } from '../onboarding/WelcomeDialog' import { RightContextPanel } from './RightContextPanel' import { GlobalAIAssistantDrawer } from '../assistant' -import { useAssistantStore } from '../../stores/assistantStore' import { ToastProvider } from '../ui' -import { useCompareStore } from '../../stores/compareStore' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface NavItem { - path: string - label: string - icon: LucideIcon -} - -interface WorkspaceConfig { - label: string - abbreviation: string - icon: LucideIcon - firstPath: string - navItems: NavItem[] - chipColor: string -} - -// --------------------------------------------------------------------------- -// Workspace configuration -// --------------------------------------------------------------------------- - -const WORKSPACE_CONFIG: Record = { - [WorkspaceType.SUPPLY]: { - label: 'Verwaltung', - abbreviation: 'VW', - icon: Building2, - firstPath: '/supply/dashboard', - chipColor: '#1e3a5f', - navItems: [ - { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, - { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, - { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, - { path: '/supply/market-leads', label: 'Markt-Leads', icon: Target }, - { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, - { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, - { path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, - { path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList }, - { path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus }, - ], - }, - [WorkspaceType.DEMAND]: { - label: 'Suche', - abbreviation: 'SU', - icon: Search, - firstPath: '/demand/ai-search', - chipColor: '#1a7a4a', - navItems: [ - { path: '/demand/ai-search', label: 'Flächensuche', icon: Search }, - { path: '/demand/results', label: 'Ergebnisse', icon: List }, - { path: '/demand/compare', label: 'Vergleich', icon: Columns2 }, - { path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban }, - { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, - ], - }, -} - -// Ordered list for rendering workspace tabs -const WORKSPACE_ORDER: WorkspaceType[] = [ - WorkspaceType.SUPPLY, - WorkspaceType.DEMAND, -] - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function getWorkspaceFromPath(pathname: string): WorkspaceType | null { - if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY - if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND - return null -} - -function getPageNameFromPath(pathname: string): string { - for (const ws of Object.values(WORKSPACE_CONFIG)) { - for (const item of ws.navItems) { - if (item.path === pathname) return item.label - } - } - if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail' - if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail' - if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail' - if (pathname === '/demand/anfragen') return 'Anfragen' - const segment = pathname.split('/').filter(Boolean).pop() ?? '' - return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') -} - -function getUserInitials(name: string): string { - return name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase() - .slice(0, 2) -} - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -const SIDEBAR_BG = '#0f1923' -const DIVIDER_COLOR = 'rgba(255,255,255,0.08)' -const TEXT_MUTED = '#94a3b8' -const TEXT_WHITE = '#ffffff' -const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)' -const NAV_HOVER_BG = 'rgba(255,255,255,0.06)' - -interface SidebarProps { - collapsed: boolean - activeWorkspace: WorkspaceType - allowedWorkspaces: WorkspaceType[] - onWorkspaceClick: (workspace: WorkspaceType) => void - onToggle: () => void - onClose?: () => void - userName: string - orgName: string -} - -function Sidebar({ - collapsed, - activeWorkspace, - allowedWorkspaces, - onWorkspaceClick, - onToggle, - onClose, - userName, - orgName, -}: SidebarProps) { - const config = WORKSPACE_CONFIG[activeWorkspace] - const compareCount = useCompareStore(s => s.compareItems.length) - const width = collapsed ? 60 : 264 - const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws)) - - return ( - - {/* Logo area */} - - {collapsed ? ( - - PM - - ) : ( - - - Property - - - Match - - - )} - - - {/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */} - {visibleWorkspaces.length > 1 && - {visibleWorkspaces.map((ws, idx) => { - const wsConfig = WORKSPACE_CONFIG[ws] - const Icon = wsConfig.icon - const isActive = ws === activeWorkspace - - const tabContent = ( - { onWorkspaceClick(ws); onClose?.() }} - sx={{ - display: 'flex', - flexDirection: collapsed ? 'row' : 'column', - alignItems: 'center', - justifyContent: 'center', - gap: collapsed ? 0 : 0.5, - py: collapsed ? 1 : 1.25, - cursor: 'pointer', - backgroundColor: isActive ? wsConfig.chipColor : 'transparent', - borderRight: !collapsed && idx < visibleWorkspaces.length - 1 - ? `1px solid ${DIVIDER_COLOR}` - : 'none', - transition: 'background-color 0.15s ease', - '&:hover': { - backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG, - }, - }} - > - - {!collapsed && ( - - {wsConfig.label} - - )} - - ) - - return collapsed ? ( - - {tabContent} - - ) : ( - {tabContent} - ) - })} - } - - {/* Nav items */} - - {config.navItems.map((item) => { - const Icon = item.icon - - const navContent = ( - onClose?.()} - > - {({ isActive }) => ( - - - {!collapsed && ( - - - {item.label} - - {item.path === '/demand/compare' && compareCount > 0 && ( - - {compareCount} - - )} - - )} - - )} - - ) - - return collapsed ? ( - - {navContent} - - ) : ( - {navContent} - ) - })} - - - {/* Bottom section */} - - {!collapsed && ( - - - {getUserInitials(userName)} - - - - {userName} - - - {orgName} - - - - )} - - - {collapsed ? : } - - - - ) -} - -interface TopBarProps { - activeWorkspace: WorkspaceType - pathname: string - onMenuClick?: () => void - isMobile?: boolean -} - -function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: TopBarProps) { - const config = WORKSPACE_CONFIG[activeWorkspace] - const pageName = getPageNameFromPath(pathname) - const openAssistant = useAssistantStore(s => s.open) - - return ( - - {/* Left side */} - - {isMobile && ( - - - - )} - - - {pageName} - - - - {/* Right side */} - - - - - - - - - - - - - ) -} +import { Sidebar } from './AppShellSidebar' +import { TopBar } from './AppShellTopBar' +import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig' +import { WorkspaceType } from '../../domain/enums' // --------------------------------------------------------------------------- // AppShell diff --git a/src/components/layout/AppShellSidebar.tsx b/src/components/layout/AppShellSidebar.tsx new file mode 100644 index 0000000..999a2d7 --- /dev/null +++ b/src/components/layout/AppShellSidebar.tsx @@ -0,0 +1,313 @@ +import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { NavLink } from 'react-router' +import { WorkspaceType } from '../../domain/enums' +import { useCompareStore } from '../../stores/compareStore' +import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig' + +// --------------------------------------------------------------------------- +// Visual constants +// --------------------------------------------------------------------------- + +export const SIDEBAR_BG = '#0f1923' +export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)' +export const TEXT_MUTED = '#94a3b8' +export const TEXT_WHITE = '#ffffff' +export const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)' +export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SidebarProps { + collapsed: boolean + activeWorkspace: WorkspaceType + allowedWorkspaces: WorkspaceType[] + onWorkspaceClick: (workspace: WorkspaceType) => void + onToggle: () => void + onClose?: () => void + userName: string + orgName: string +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function Sidebar({ + collapsed, + activeWorkspace, + allowedWorkspaces, + onWorkspaceClick, + onToggle, + onClose, + userName, + orgName, +}: SidebarProps) { + const config = WORKSPACE_CONFIG[activeWorkspace] + const compareCount = useCompareStore(s => s.compareItems.length) + const width = collapsed ? 60 : 264 + const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws)) + + return ( + + {/* Logo area */} + + {collapsed ? ( + + PM + + ) : ( + + + Property + + + Match + + + )} + + + {/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */} + {visibleWorkspaces.length > 1 && + {visibleWorkspaces.map((ws, idx) => { + const wsConfig = WORKSPACE_CONFIG[ws] + const Icon = wsConfig.icon + const isActive = ws === activeWorkspace + + const tabContent = ( + { onWorkspaceClick(ws); onClose?.() }} + sx={{ + display: 'flex', + flexDirection: collapsed ? 'row' : 'column', + alignItems: 'center', + justifyContent: 'center', + gap: collapsed ? 0 : 0.5, + py: collapsed ? 1 : 1.25, + cursor: 'pointer', + backgroundColor: isActive ? wsConfig.chipColor : 'transparent', + borderRight: !collapsed && idx < visibleWorkspaces.length - 1 + ? `1px solid ${DIVIDER_COLOR}` + : 'none', + transition: 'background-color 0.15s ease', + '&:hover': { + backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG, + }, + }} + > + + {!collapsed && ( + + {wsConfig.label} + + )} + + ) + + return collapsed ? ( + + {tabContent} + + ) : ( + {tabContent} + ) + })} + } + + {/* Nav items */} + + {config.navItems.map((item) => { + const Icon = item.icon + + const navContent = ( + onClose?.()} + > + {({ isActive }) => ( + + + {!collapsed && ( + + + {item.label} + + {item.path === '/demand/compare' && compareCount > 0 && ( + + {compareCount} + + )} + + )} + + )} + + ) + + return collapsed ? ( + + {navContent} + + ) : ( + {navContent} + ) + })} + + + {/* Bottom section */} + + {!collapsed && ( + + + {getUserInitials(userName)} + + + + {userName} + + + {orgName} + + + + )} + + + {collapsed ? : } + + + + ) +} diff --git a/src/components/layout/AppShellTopBar.tsx b/src/components/layout/AppShellTopBar.tsx new file mode 100644 index 0000000..846049f --- /dev/null +++ b/src/components/layout/AppShellTopBar.tsx @@ -0,0 +1,92 @@ +import { Box, Typography, Chip, Button, IconButton } from '@mui/material' +import { Sparkles, Menu } from 'lucide-react' +import { WorkspaceType } from '../../domain/enums' +import { useAssistantStore } from '../../stores/assistantStore' +import { OrganizationContextBadge } from './OrganizationContextBadge' +import { NotificationButton } from './NotificationButton' +import { UserMenu } from './UserMenu' +import { WORKSPACE_CONFIG, getPageNameFromPath } from './appShellConfig' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TopBarProps { + activeWorkspace: WorkspaceType + pathname: string + onMenuClick?: () => void + isMobile?: boolean +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: TopBarProps) { + const config = WORKSPACE_CONFIG[activeWorkspace] + const pageName = getPageNameFromPath(pathname) + const openAssistant = useAssistantStore(s => s.open) + + return ( + + {/* Left side */} + + {isMobile && ( + + + + )} + + + {pageName} + + + + {/* Right side */} + + + + + + + + + + + + + ) +} diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts new file mode 100644 index 0000000..98ceaab --- /dev/null +++ b/src/components/layout/appShellConfig.ts @@ -0,0 +1,114 @@ +import { WorkspaceType } from '../../domain/enums' +import type { LucideIcon } from 'lucide-react' +import { + LayoutDashboard, + Building2, + Target, + CheckSquare, + Search, + List, + Columns2, + ClipboardList, + Radar, + MessageSquare, + Kanban, + BellRing, + Plus, +} from 'lucide-react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface NavItem { + path: string + label: string + icon: LucideIcon +} + +export interface WorkspaceConfig { + label: string + abbreviation: string + icon: LucideIcon + firstPath: string + navItems: NavItem[] + chipColor: string +} + +// --------------------------------------------------------------------------- +// Workspace configuration +// --------------------------------------------------------------------------- + +export const WORKSPACE_CONFIG: Record = { + [WorkspaceType.SUPPLY]: { + label: 'Verwaltung', + abbreviation: 'VW', + icon: Building2, + firstPath: '/supply/dashboard', + chipColor: '#1e3a5f', + navItems: [ + { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, + { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, + { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, + { path: '/supply/market-leads', label: 'Markt-Leads', icon: Target }, + { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, + { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, + { path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, + { path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList }, + { path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus }, + ], + }, + [WorkspaceType.DEMAND]: { + label: 'Suche', + abbreviation: 'SU', + icon: Search, + firstPath: '/demand/ai-search', + chipColor: '#1a7a4a', + navItems: [ + { path: '/demand/ai-search', label: 'Flächensuche', icon: Search }, + { path: '/demand/results', label: 'Ergebnisse', icon: List }, + { path: '/demand/compare', label: 'Vergleich', icon: Columns2 }, + { path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban }, + { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, + ], + }, +} + +// Ordered list for rendering workspace tabs +export const WORKSPACE_ORDER: WorkspaceType[] = [ + WorkspaceType.SUPPLY, + WorkspaceType.DEMAND, +] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function getWorkspaceFromPath(pathname: string): WorkspaceType | null { + if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY + if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND + return null +} + +export function getPageNameFromPath(pathname: string): string { + for (const ws of Object.values(WORKSPACE_CONFIG)) { + for (const item of ws.navItems) { + if (item.path === pathname) return item.label + } + } + if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail' + if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail' + if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail' + if (pathname === '/demand/anfragen') return 'Anfragen' + const segment = pathname.split('/').filter(Boolean).pop() ?? '' + return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') +} + +export function getUserInitials(name: string): string { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} diff --git a/src/components/match-detail/CriterionRow.tsx b/src/components/match-detail/CriterionRow.tsx new file mode 100644 index 0000000..516c7b3 --- /dev/null +++ b/src/components/match-detail/CriterionRow.tsx @@ -0,0 +1,56 @@ +import { Box, LinearProgress, Typography } from '@mui/material' +import type { ScoreFactor } from '../../domain/match' +import { factorLabel, importanceLabel } from './scoreBreakdownConstants' + +function scoreColor(v: number): 'success' | 'warning' | 'error' { + return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error' +} + +function scoreTextColor(v: number): string { + return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b' +} + +export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) { + const label = factorLabel(factor.criterion) + const pct = Math.round(factor.weight * 100) + const color = scoreColor(factor.score) + const imp = importanceLabel(factor.weight, maxWeight) + + return ( + + + + {label} + + {imp.label} + + {factor.estimated && ( + + Schätzung + + )} + + {pct}% + + + + + {factor.score}/100 + + + {factor.contribution.toFixed(1)} Pkt + + + + + + {factor.explanation} + + + ) +} diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx index afddc71..7c1a36b 100644 --- a/src/components/match-detail/FutureAvailabilityContextPanel.tsx +++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx @@ -1,54 +1,19 @@ import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material' import { AlertTriangle, - BarChart2, - Briefcase, Calendar, CheckCircle2, Clock, ExternalLink, - FileCheck, - FileText, Globe, - Newspaper, ShieldCheck, Sparkles, TrendingUp, - User, Zap, } from 'lucide-react' import type { Match } from '../../domain/match' import type { FutureSignal } from '../../domain/futureSignal' - -const SIGNAL_TYPE_LABELS: Record = { - EXPANSION: 'Expansion', - POSSIBLE_MOVE_OUT: 'Möglicher Auszug', - CONSTRUCTION_PROJECT: 'Bauvorhaben', - RESTRUCTURING: 'Restrukturierung', - PROJECT_DEVELOPMENT: 'Projektentwicklung', - SPACE_CONSOLIDATION: 'Flächenkonsolidierung', - LEASE_EXPIRY: 'Vertragsende (Pre-Market)', -} - -const SIGNAL_ACTION: Record = { - EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' }, - POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' }, - CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' }, - RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' }, - PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' }, - SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' }, - LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' }, -} - -const SOURCE_META: Record = { - JOB_POSTING: { label: 'Stelleninserate', icon: }, - PRESS: { label: 'Pressebericht', icon: }, - CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: }, - COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, - MARKET_DATA: { label: 'Marktdaten', icon: }, - MANUAL: { label: 'Analyst', icon: }, - LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: }, -} +import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants' const CREDIBILITY_META: Record = { HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' }, diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx index 2a1705d..834286a 100644 --- a/src/components/match-detail/LocationIntelligencePanel.tsx +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -1,4 +1,4 @@ -import { Box, Chip, Divider, LinearProgress, Paper, Tooltip, Typography } from '@mui/material' +import { Box, Chip, Divider, Paper, Typography } from '@mui/material' import { Activity, Building2, HardHat, MapPin, Percent, TrendingDown, TrendingUp, Train, Users, Zap, @@ -7,60 +7,10 @@ import { useNavigate } from 'react-router' import { useProperties } from '../../hooks/useProperties' import { getCityIntelligence } from '../../lib/locationIntelligence' import type { Property } from '../../domain/property' - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function scoreColor(v: number) { - if (v >= 0.72) return '#1a7a4a' - if (v >= 0.48) return '#d97706' - return '#c0392b' -} - -function scoreLabel(v: number) { - if (v >= 0.82) return 'Sehr gut' - if (v >= 0.65) return 'Gut' - if (v >= 0.45) return 'Mittel' - return 'Schwach' -} +import { SoftFactorBar } from './SoftFactorBar' // ── Sub-components ──────────────────────────────────────────────────────────── -function SoftFactorBar({ - label, - value, - icon, - tooltip, -}: { - label: string - value: number | undefined | null - icon: React.ReactNode - tooltip?: string -}) { - if (value === undefined || value === null) return null - const color = scoreColor(value) - const row = ( - - - - {icon} - {label} - - - - - - ) - return tooltip ? {row} : row -} - function KpiTile({ label, value, diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx index d7d323f..ea235cc 100644 --- a/src/components/match-detail/ScoreBreakdownPanel.tsx +++ b/src/components/match-detail/ScoreBreakdownPanel.tsx @@ -1,8 +1,10 @@ import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material' import { CheckCircle2, ShieldCheck, X } from 'lucide-react' import { ExternalLink } from 'lucide-react' -import type { Match, ScoreFactor } from '../../domain/match' +import type { Match } from '../../domain/match' import type { FutureSignal } from '../../domain/futureSignal' +import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants' +import { CriterionRow } from './CriterionRow' // ── Shared helpers ───────────────────────────────────────────────────────────── @@ -14,82 +16,6 @@ function scoreTextColor(v: number): string { return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b' } -const CREDIBILITY_LABELS: Record = { - HIGH: 'Hohe Quellenqualität', - MEDIUM: 'Mittlere Quellenqualität', - LOW: 'Niedrige Quellenqualität', -} - -const CRITERION_LABEL: Record = { - area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit', - prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion', - flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz', - talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld', -} - -const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing']) - -function factorLabel(criterion: string): string { - return CRITERION_LABEL[criterion] ?? criterion -} - -// Convert normalised weight to 1-5 importance level relative to other factors in the same set -function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { - const ratio = maxWeight > 0 ? weight / maxWeight : 0 - if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' } - if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' } - if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } - if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } - return { label: 'Unwichtig', color: '#cbd5e1' } -} - -// ── Single criterion row ─────────────────────────────────────────────────────── - -function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) { - const label = factorLabel(factor.criterion) - const pct = Math.round(factor.weight * 100) - const color = scoreColor(factor.score) - const imp = importanceLabel(factor.weight, maxWeight) - - return ( - - - - {label} - - {imp.label} - - {factor.estimated && ( - - Schätzung - - )} - - {pct}% - - - - - {factor.score}/100 - - - {factor.contribution.toFixed(1)} Pkt - - - - - - {factor.explanation} - - - ) -} - // ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ────────────── interface StandardBreakdownProps { diff --git a/src/components/match-detail/SoftFactorBar.tsx b/src/components/match-detail/SoftFactorBar.tsx new file mode 100644 index 0000000..0312661 --- /dev/null +++ b/src/components/match-detail/SoftFactorBar.tsx @@ -0,0 +1,50 @@ +import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material' + +function scoreColor(v: number) { + if (v >= 0.72) return '#1a7a4a' + if (v >= 0.48) return '#d97706' + return '#c0392b' +} + +function scoreLabel(v: number) { + if (v >= 0.82) return 'Sehr gut' + if (v >= 0.65) return 'Gut' + if (v >= 0.45) return 'Mittel' + return 'Schwach' +} + +export function SoftFactorBar({ + label, + value, + icon, + tooltip, +}: { + label: string + value: number | undefined | null + icon: React.ReactNode + tooltip?: string +}) { + if (value === undefined || value === null) return null + const color = scoreColor(value) + const row = ( + + + + {icon} + {label} + + + + + + ) + return tooltip ? {row} : row +} diff --git a/src/components/match-detail/futureAvailabilityConstants.tsx b/src/components/match-detail/futureAvailabilityConstants.tsx new file mode 100644 index 0000000..855acc9 --- /dev/null +++ b/src/components/match-detail/futureAvailabilityConstants.tsx @@ -0,0 +1,31 @@ +import { BarChart2, Briefcase, FileCheck, FileText, Newspaper, ShieldCheck, User } from 'lucide-react' + +export const SIGNAL_TYPE_LABELS: Record = { + EXPANSION: 'Expansion', + POSSIBLE_MOVE_OUT: 'Möglicher Auszug', + CONSTRUCTION_PROJECT: 'Bauvorhaben', + RESTRUCTURING: 'Restrukturierung', + PROJECT_DEVELOPMENT: 'Projektentwicklung', + SPACE_CONSOLIDATION: 'Flächenkonsolidierung', + LEASE_EXPIRY: 'Vertragsende (Pre-Market)', +} + +export const SIGNAL_ACTION: Record = { + EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' }, + POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' }, + CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' }, + RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' }, + PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' }, + SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' }, + LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' }, +} + +export const SOURCE_META: Record = { + JOB_POSTING: { label: 'Stelleninserate', icon: }, + PRESS: { label: 'Pressebericht', icon: }, + CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: }, + COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, + MARKET_DATA: { label: 'Marktdaten', icon: }, + MANUAL: { label: 'Analyst', icon: }, + LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: }, +} diff --git a/src/components/match-detail/scoreBreakdownConstants.ts b/src/components/match-detail/scoreBreakdownConstants.ts new file mode 100644 index 0000000..37971e6 --- /dev/null +++ b/src/components/match-detail/scoreBreakdownConstants.ts @@ -0,0 +1,28 @@ +export const CREDIBILITY_LABELS: Record = { + HIGH: 'Hohe Quellenqualität', + MEDIUM: 'Mittlere Quellenqualität', + LOW: 'Niedrige Quellenqualität', +} + +export const CRITERION_LABEL: Record = { + area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit', + prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion', + flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz', + talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld', +} + +export const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing']) + +export function factorLabel(criterion: string): string { + return CRITERION_LABEL[criterion] ?? criterion +} + +// Convert normalised weight to 1-5 importance level relative to other factors in the same set +export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { + const ratio = maxWeight > 0 ? weight / maxWeight : 0 + if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' } + if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' } + if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } + if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } + return { label: 'Unwichtig', color: '#cbd5e1' } +} diff --git a/src/hooks/useCompareData.ts b/src/hooks/useCompareData.ts new file mode 100644 index 0000000..27af8b5 --- /dev/null +++ b/src/hooks/useCompareData.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query' +import type { UnifiedMatchResult } from '../domain/unifiedResult' +import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../domain/needBuilder' +import type { WeightingKey } from '../domain/needBuilder' +import { aiService } from '../services/aiService' +import { needService } from '../services/needService' +import { CRITERION_ALIASES, getProp } from '../components/compare/compareUtils' + +export function useCompareData(compareItems: UnifiedMatchResult[]) { + const { data: aiSummary, isLoading: aiLoading } = useQuery({ + queryKey: ['ai-compare', compareItems.map(i => i.matchId)], + queryFn: () => aiService.summarizeComparison(compareItems), + enabled: compareItems.length >= 2, + select: r => r.data, + staleTime: Infinity, + }) + + const { data: needsData } = useQuery({ + queryKey: ['needs'], + queryFn: () => needService.getAll(), + select: r => r.data, + }) + + const activeNeed = needsData + ? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + : undefined + + const relevantCriteria = activeNeed + ? WEIGHTING_KEYS + .map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 })) + .filter(c => c.weight > 0) + .sort((a, b) => b.weight - a.weight) + : [] + + const weightedTotals = compareItems.map(item => + relevantCriteria.reduce((sum, { key, weight }) => { + const factor = [...item.match.positiveFactors, ...item.match.negativeFactors] + .find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) + return sum + (factor?.score ?? 50) * weight + }, 0) + ) + const maxWeightedTotal = Math.max(...weightedTotals) + const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1 + ? weightedTotals.indexOf(maxWeightedTotal) + : -1 + + const bestScoreIdx = compareItems.length > 0 + ? compareItems.reduce( + (best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0 + ) + : -1 + + const worstConfIdx = compareItems.length > 0 + ? compareItems.reduce( + (worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0 + ) + : -1 + + const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1) + const worstDQIdx = dqScores.length > 0 ? dqScores.indexOf(Math.min(...dqScores)) : -1 + + const missingCriticalCounts = compareItems.map( + item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0 + ) + const maxMissingCritical = missingCriticalCounts.length > 0 ? Math.max(...missingCriticalCounts) : 0 + + return { + aiSummary, + aiLoading, + activeNeed, + relevantCriteria, + weightedTotals, + overallWinnerIdx, + bestScoreIdx, + worstConfIdx, + dqScores, + worstDQIdx, + missingCriticalCounts, + maxMissingCritical, + } +} diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index 6962d45..d42a734 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -15,12 +15,7 @@ import { } from '@mui/material' import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react' import { useNavigate } from 'react-router' -import { useQuery } from '@tanstack/react-query' import { useCompareStore } from '../../stores/compareStore' -import { aiService } from '../../services/aiService' -import { needService } from '../../services/needService' -import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' -import type { WeightingKey } from '../../domain/needBuilder' import { CompareEmptyState, CompareColumnHeader, @@ -35,13 +30,13 @@ import { SCORE_COLOR, TYPE_META, RISK_LEVEL_ORDER, - CRITERION_ALIASES, getProp, getSig, LABEL_SX, DATA_SX, scoreBar, } from '../../components/compare/compareUtils' +import { useCompareData } from '../../hooks/useCompareData' // ── Module-level helpers ────────────────────────────────────────────────────── @@ -62,42 +57,19 @@ export default function Compare() { const navigate = useNavigate() const { compareItems, removeFromCompare, clearCompare } = useCompareStore() - const { data: aiSummary, isLoading: aiLoading } = useQuery({ - queryKey: ['ai-compare', compareItems.map(i => i.matchId)], - queryFn: () => aiService.summarizeComparison(compareItems), - enabled: compareItems.length >= 2, - select: r => r.data, - staleTime: Infinity, - }) - - const { data: needsData } = useQuery({ - queryKey: ['needs'], - queryFn: () => needService.getAll(), - select: r => r.data, - }) - - const activeNeed = needsData - ? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] - : undefined - - const relevantCriteria = activeNeed - ? WEIGHTING_KEYS - .map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 })) - .filter(c => c.weight > 0) - .sort((a, b) => b.weight - a.weight) - : [] - - const weightedTotals = compareItems.map(item => - relevantCriteria.reduce((sum, { key, weight }) => { - const factor = [...item.match.positiveFactors, ...item.match.negativeFactors] - .find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) - return sum + (factor?.score ?? 50) * weight - }, 0) - ) - const maxWeightedTotal = Math.max(...weightedTotals) - const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1 - ? weightedTotals.indexOf(maxWeightedTotal) - : -1 + const { + aiSummary, + aiLoading, + activeNeed, + relevantCriteria, + overallWinnerIdx, + bestScoreIdx, + worstConfIdx, + dqScores, + worstDQIdx, + missingCriticalCounts, + maxMissingCritical, + } = useCompareData(compareItems) if (compareItems.length === 0) { return ( @@ -110,22 +82,6 @@ export default function Compare() { ) } - // ── Highlight indices ────────────────────────────────────────────────────── - - const bestScoreIdx = compareItems.reduce( - (best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0 - ) - const worstConfIdx = compareItems.reduce( - (worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0 - ) - const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1) - const worstDQIdx = dqScores.indexOf(Math.min(...dqScores)) - - const missingCriticalCounts = compareItems.map( - item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0 - ) - const maxMissingCritical = Math.max(...missingCriticalCounts) - return (