refactor: split AppShell, match-detail panels, extract useCompareData
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,527 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
import { Outlet, useNavigate, useLocation } from 'react-router'
|
||||||
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
import { Box, Drawer, useTheme, useMediaQuery } from '@mui/material'
|
||||||
import { useLayoutStore } from '../../stores/layoutStore'
|
import { useLayoutStore } from '../../stores/layoutStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { WorkspaceType } from '../../domain/enums'
|
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
||||||
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 { RightContextPanel } from './RightContextPanel'
|
import { RightContextPanel } from './RightContextPanel'
|
||||||
import { GlobalAIAssistantDrawer } from '../assistant'
|
import { GlobalAIAssistantDrawer } from '../assistant'
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
|
||||||
import { ToastProvider } from '../ui'
|
import { ToastProvider } from '../ui'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { Sidebar } from './AppShellSidebar'
|
||||||
|
import { TopBar } from './AppShellTopBar'
|
||||||
// ---------------------------------------------------------------------------
|
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig'
|
||||||
// Types
|
import { WorkspaceType } from '../../domain/enums'
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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, WorkspaceConfig> = {
|
|
||||||
[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 (
|
|
||||||
<Box
|
|
||||||
component="nav"
|
|
||||||
sx={{
|
|
||||||
width,
|
|
||||||
minWidth: width,
|
|
||||||
flexShrink: 0,
|
|
||||||
height: '100vh',
|
|
||||||
backgroundColor: SIDEBAR_BG,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
transition: 'width 0.2s ease',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Logo area */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 56,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
px: collapsed ? 0 : 2.5,
|
|
||||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
PM
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
|
||||||
>
|
|
||||||
Property
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: '#64b5f6',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Match
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */}
|
|
||||||
{visibleWorkspaces.length > 1 && <Box
|
|
||||||
sx={{
|
|
||||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: collapsed ? '1fr' : `repeat(${visibleWorkspaces.length}, 1fr)`,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleWorkspaces.map((ws, idx) => {
|
|
||||||
const wsConfig = WORKSPACE_CONFIG[ws]
|
|
||||||
const Icon = wsConfig.icon
|
|
||||||
const isActive = ws === activeWorkspace
|
|
||||||
|
|
||||||
const tabContent = (
|
|
||||||
<Box
|
|
||||||
onClick={() => { onWorkspaceClick(ws); onClose?.() }}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: collapsed ? 'row' : 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: collapsed ? 0 : 0.5,
|
|
||||||
py: collapsed ? 1 : 1.25,
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: isActive ? wsConfig.chipColor : 'transparent',
|
|
||||||
borderRight: !collapsed && idx < visibleWorkspaces.length - 1
|
|
||||||
? `1px solid ${DIVIDER_COLOR}`
|
|
||||||
: 'none',
|
|
||||||
transition: 'background-color 0.15s ease',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={15} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
|
||||||
{!collapsed && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
|
||||||
fontWeight: isActive ? 700 : 400,
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wsConfig.label}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
return collapsed ? (
|
|
||||||
<Tooltip key={ws} title={wsConfig.label} placement="right">
|
|
||||||
{tabContent}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Box key={ws}>{tabContent}</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>}
|
|
||||||
|
|
||||||
{/* Nav items */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
|
|
||||||
{config.navItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
|
|
||||||
const navContent = (
|
|
||||||
<NavLink
|
|
||||||
to={item.path}
|
|
||||||
style={{ textDecoration: 'none', display: 'block' }}
|
|
||||||
onClick={() => onClose?.()}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.25,
|
|
||||||
px: collapsed ? 0 : 1.5,
|
|
||||||
py: 0.75,
|
|
||||||
mx: 0.5,
|
|
||||||
borderRadius: 1,
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
|
|
||||||
},
|
|
||||||
transition: 'background-color 0.15s ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
|
||||||
{!collapsed && (
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
|
||||||
fontWeight: isActive ? 500 : 400,
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
{item.path === '/demand/compare' && compareCount > 0 && (
|
|
||||||
<Box sx={{
|
|
||||||
minWidth: 18, height: 18, borderRadius: '9px',
|
|
||||||
bgcolor: '#d97706', color: 'white',
|
|
||||||
fontSize: '0.65rem', fontWeight: 700,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
px: 0.5, flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{compareCount}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
|
|
||||||
return collapsed ? (
|
|
||||||
<Tooltip key={item.path} title={item.label} placement="right">
|
|
||||||
<Box>{navContent}</Box>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Box key={item.path}>{navContent}</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Bottom section */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderTop: `1px solid ${DIVIDER_COLOR}`,
|
|
||||||
px: collapsed ? 0 : 1.5,
|
|
||||||
py: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
justifyContent: collapsed ? 'center' : 'space-between',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!collapsed && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getUserInitials(userName)}
|
|
||||||
</Avatar>
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: TEXT_WHITE,
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{userName}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: '#64748b',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{orgName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
onClick={onToggle}
|
|
||||||
size="small"
|
|
||||||
sx={{ color: '#64748b', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Box
|
|
||||||
component="header"
|
|
||||||
sx={{
|
|
||||||
height: 56,
|
|
||||||
flexShrink: 0,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderBottom: '1px solid #e2e8f0',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: { xs: 1.5, sm: 3 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left side */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{isMobile && (
|
|
||||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
|
||||||
<Menu size={20} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<Chip
|
|
||||||
label={config.label}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: config.chipColor,
|
|
||||||
color: '#ffffff',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: 22,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
|
|
||||||
>
|
|
||||||
{pageName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right side */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
|
||||||
<OrganizationContextBadge />
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<Sparkles size={14} />}
|
|
||||||
onClick={openAssistant}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.8125rem', display: { xs: 'none', md: 'flex' } }}
|
|
||||||
>
|
|
||||||
AI Assistent
|
|
||||||
</Button>
|
|
||||||
<IconButton size="small" onClick={openAssistant} sx={{ display: { xs: 'flex', md: 'none' } }}>
|
|
||||||
<Sparkles size={18} />
|
|
||||||
</IconButton>
|
|
||||||
<NotificationButton />
|
|
||||||
<UserMenu />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppShell
|
// AppShell
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{
|
||||||
|
width,
|
||||||
|
minWidth: width,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: SIDEBAR_BG,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo area */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 56,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
px: collapsed ? 0 : 2.5,
|
||||||
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
|
PM
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
||||||
|
>
|
||||||
|
Property
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64b5f6',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Match
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */}
|
||||||
|
{visibleWorkspaces.length > 1 && <Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: collapsed ? '1fr' : `repeat(${visibleWorkspaces.length}, 1fr)`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleWorkspaces.map((ws, idx) => {
|
||||||
|
const wsConfig = WORKSPACE_CONFIG[ws]
|
||||||
|
const Icon = wsConfig.icon
|
||||||
|
const isActive = ws === activeWorkspace
|
||||||
|
|
||||||
|
const tabContent = (
|
||||||
|
<Box
|
||||||
|
onClick={() => { onWorkspaceClick(ws); onClose?.() }}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: collapsed ? 'row' : 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: collapsed ? 0 : 0.5,
|
||||||
|
py: collapsed ? 1 : 1.25,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: isActive ? wsConfig.chipColor : 'transparent',
|
||||||
|
borderRight: !collapsed && idx < visibleWorkspaces.length - 1
|
||||||
|
? `1px solid ${DIVIDER_COLOR}`
|
||||||
|
: 'none',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={15} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||||
|
{!collapsed && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wsConfig.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={ws} title={wsConfig.label} placement="right">
|
||||||
|
{tabContent}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box key={ws}>{tabContent}</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>}
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
|
||||||
|
{config.navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
style={{ textDecoration: 'none', display: 'block' }}
|
||||||
|
onClick={() => onClose?.()}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
px: collapsed ? 0 : 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
mx: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
|
||||||
|
},
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||||
|
{!collapsed && (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
|
fontWeight: isActive ? 500 : 400,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
{item.path === '/demand/compare' && compareCount > 0 && (
|
||||||
|
<Box sx={{
|
||||||
|
minWidth: 18, height: 18, borderRadius: '9px',
|
||||||
|
bgcolor: '#d97706', color: 'white',
|
||||||
|
fontSize: '0.65rem', fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
px: 0.5, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{compareCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={item.path} title={item.label} placement="right">
|
||||||
|
<Box>{navContent}</Box>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box key={item.path}>{navContent}</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
px: collapsed ? 0 : 1.5,
|
||||||
|
py: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
justifyContent: collapsed ? 'center' : 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!collapsed && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getUserInitials(userName)}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: TEXT_WHITE,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{orgName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: '#64748b', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
height: 56,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: { xs: 1.5, sm: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||||
|
<Menu size={20} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={config.label}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: config.chipColor,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
|
||||||
|
>
|
||||||
|
{pageName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
||||||
|
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||||
|
<OrganizationContextBadge />
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Sparkles size={14} />}
|
||||||
|
onClick={openAssistant}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.8125rem', display: { xs: 'none', md: 'flex' } }}
|
||||||
|
>
|
||||||
|
AI Assistent
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" onClick={openAssistant} sx={{ display: { xs: 'flex', md: 'none' } }}>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<NotificationButton />
|
||||||
|
<UserMenu />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, WorkspaceConfig> = {
|
||||||
|
[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)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
|
||||||
|
{imp.label}
|
||||||
|
</Typography>
|
||||||
|
{factor.estimated && (
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.62rem', color: '#7c3aed', bgcolor: '#f5f3ff', px: 0.5, borderRadius: 0.5, border: '1px solid #e9d5ff', lineHeight: 1.6 }}>
|
||||||
|
Schätzung
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
|
||||||
|
{pct}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
||||||
|
{factor.score}/100
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
||||||
|
{factor.contribution.toFixed(1)} Pkt
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={factor.score}
|
||||||
|
color={color}
|
||||||
|
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
|
||||||
|
{factor.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,54 +1,19 @@
|
|||||||
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BarChart2,
|
|
||||||
Briefcase,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileCheck,
|
|
||||||
FileText,
|
|
||||||
Globe,
|
Globe,
|
||||||
Newspaper,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
User,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
|
||||||
const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|
||||||
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<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
|
||||||
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<string, { label: string; icon: React.ReactNode }> = {
|
|
||||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
|
|
||||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
|
|
||||||
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
|
|
||||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
|
||||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
|
||||||
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
|
||||||
LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: <ShieldCheck size={13} /> },
|
|
||||||
}
|
|
||||||
|
|
||||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||||
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Activity, Building2, HardHat, MapPin, Percent,
|
Activity, Building2, HardHat, MapPin, Percent,
|
||||||
TrendingDown, TrendingUp, Train, Users, Zap,
|
TrendingDown, TrendingUp, Train, Users, Zap,
|
||||||
@@ -7,60 +7,10 @@ import { useNavigate } from 'react-router'
|
|||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
|
import { SoftFactorBar } from './SoftFactorBar'
|
||||||
// ── 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'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
// ── 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 = (
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.4 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<Box sx={{ color: '#64748b', display: 'flex' }}>{icon}</Box>
|
|
||||||
<Typography variant="body2">{label}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
label={scoreLabel(value)}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: color, color: 'white', fontSize: 10, height: 18, fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={value * 100}
|
|
||||||
sx={{ height: 5, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
return tooltip ? <Tooltip title={tooltip} placement="left">{row}</Tooltip> : row
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiTile({
|
function KpiTile({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
||||||
import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
|
import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
|
||||||
import { ExternalLink } 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 type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
|
||||||
|
import { CriterionRow } from './CriterionRow'
|
||||||
|
|
||||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -14,82 +16,6 @@ function scoreTextColor(v: number): string {
|
|||||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREDIBILITY_LABELS: Record<string, string> = {
|
|
||||||
HIGH: 'Hohe Quellenqualität',
|
|
||||||
MEDIUM: 'Mittlere Quellenqualität',
|
|
||||||
LOW: 'Niedrige Quellenqualität',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CRITERION_LABEL: Record<string, string> = {
|
|
||||||
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 (
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
|
|
||||||
{imp.label}
|
|
||||||
</Typography>
|
|
||||||
{factor.estimated && (
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.62rem', color: '#7c3aed', bgcolor: '#f5f3ff', px: 0.5, borderRadius: 0.5, border: '1px solid #e9d5ff', lineHeight: 1.6 }}>
|
|
||||||
Schätzung
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
|
|
||||||
{pct}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
|
||||||
{factor.score}/100
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
|
||||||
{factor.contribution.toFixed(1)} Pkt
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={factor.score}
|
|
||||||
color={color}
|
|
||||||
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
|
|
||||||
{factor.explanation}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||||
|
|
||||||
interface StandardBreakdownProps {
|
interface StandardBreakdownProps {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Box sx={{ color: '#64748b', display: 'flex' }}>{icon}</Box>
|
||||||
|
<Typography variant="body2">{label}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={scoreLabel(value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: color, color: 'white', fontSize: 10, height: 18, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={value * 100}
|
||||||
|
sx={{ height: 5, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
return tooltip ? <Tooltip title={tooltip} placement="left">{row}</Tooltip> : row
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BarChart2, Briefcase, FileCheck, FileText, Newspaper, ShieldCheck, User } from 'lucide-react'
|
||||||
|
|
||||||
|
export const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
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<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
||||||
|
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<string, { label: string; icon: React.ReactNode }> = {
|
||||||
|
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
|
||||||
|
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
|
||||||
|
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
|
||||||
|
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
||||||
|
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
||||||
|
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
||||||
|
LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: <ShieldCheck size={13} /> },
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const CREDIBILITY_LABELS: Record<string, string> = {
|
||||||
|
HIGH: 'Hohe Quellenqualität',
|
||||||
|
MEDIUM: 'Mittlere Quellenqualität',
|
||||||
|
LOW: 'Niedrige Quellenqualität',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CRITERION_LABEL: Record<string, string> = {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react'
|
import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
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 {
|
import {
|
||||||
CompareEmptyState,
|
CompareEmptyState,
|
||||||
CompareColumnHeader,
|
CompareColumnHeader,
|
||||||
@@ -35,13 +30,13 @@ import {
|
|||||||
SCORE_COLOR,
|
SCORE_COLOR,
|
||||||
TYPE_META,
|
TYPE_META,
|
||||||
RISK_LEVEL_ORDER,
|
RISK_LEVEL_ORDER,
|
||||||
CRITERION_ALIASES,
|
|
||||||
getProp,
|
getProp,
|
||||||
getSig,
|
getSig,
|
||||||
LABEL_SX,
|
LABEL_SX,
|
||||||
DATA_SX,
|
DATA_SX,
|
||||||
scoreBar,
|
scoreBar,
|
||||||
} from '../../components/compare/compareUtils'
|
} from '../../components/compare/compareUtils'
|
||||||
|
import { useCompareData } from '../../hooks/useCompareData'
|
||||||
|
|
||||||
// ── Module-level helpers ──────────────────────────────────────────────────────
|
// ── Module-level helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,42 +57,19 @@ export default function Compare() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
||||||
|
|
||||||
const { data: aiSummary, isLoading: aiLoading } = useQuery({
|
const {
|
||||||
queryKey: ['ai-compare', compareItems.map(i => i.matchId)],
|
aiSummary,
|
||||||
queryFn: () => aiService.summarizeComparison(compareItems),
|
aiLoading,
|
||||||
enabled: compareItems.length >= 2,
|
activeNeed,
|
||||||
select: r => r.data,
|
relevantCriteria,
|
||||||
staleTime: Infinity,
|
overallWinnerIdx,
|
||||||
})
|
bestScoreIdx,
|
||||||
|
worstConfIdx,
|
||||||
const { data: needsData } = useQuery({
|
dqScores,
|
||||||
queryKey: ['needs'],
|
worstDQIdx,
|
||||||
queryFn: () => needService.getAll(),
|
missingCriticalCounts,
|
||||||
select: r => r.data,
|
maxMissingCritical,
|
||||||
})
|
} = useCompareData(compareItems)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if (compareItems.length === 0) {
|
if (compareItems.length === 0) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
|||||||
Reference in New Issue
Block a user