feat: merge Markt-Leads + Markt Intelligence → Marktchancen
Two-pane UX (from Market Intelligence) with supply-relevant data (FutureSignal + portfolio property matches from Markt-Leads): - Left pane: filterable signal list with probability badge + portfolio match count - Right pane: company overview, KI-Analyse, market indicators, Faktencheck, portfolio matches (click → Meine Objekte), Reminder erstellen action - No OPS-level actions (approve/reject/review stripped out) - Nav: remove Markt-Leads entry, rename to Marktchancen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-3
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
|||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
|||||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +61,6 @@ function App() {
|
|||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -49,10 +48,9 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
{ 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/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||||
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { memo, useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { Box, Chip, Divider, Skeleton, Typography, Button } from '@mui/material'
|
||||||
|
import { AlertCircle, Bell, Building2, CheckCircle2, Sparkles, TrendingUp } from 'lucide-react'
|
||||||
|
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
||||||
|
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
JOB_POSTING: 'Stelleninserate',
|
||||||
|
PRESS: 'Pressebericht',
|
||||||
|
COMPANY_REPORT: 'Geschäftsbericht',
|
||||||
|
MARKET_DATA: 'Marktdaten',
|
||||||
|
MANUAL: 'Manuell',
|
||||||
|
CONSTRUCTION_PERMIT: 'Baugenehmigung',
|
||||||
|
LEASE_CONTRACT: 'Mietvertrag',
|
||||||
|
}
|
||||||
|
|
||||||
|
function probColor(p: number): string {
|
||||||
|
if (p >= 0.70) return '#1a7a4a'
|
||||||
|
if (p >= 0.50) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeadListItem = memo(function LeadListItem({
|
||||||
|
lead, selected, onClick,
|
||||||
|
}: {
|
||||||
|
lead: MarketLead
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const { signal, matchingProperties } = lead
|
||||||
|
const prob = Math.round(signal.probability * 100)
|
||||||
|
const color = probColor(signal.probability)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
px: 2, py: 1.5,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: `3px solid ${selected ? '#1e3a5f' : 'transparent'}`,
|
||||||
|
bgcolor: selected ? '#f0f9ff' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.25 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
|
||||||
|
{signal.companyName ?? signal.locationHint}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${prob}%`}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', fontWeight: 700, bgcolor: `${color}18`, color, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{signal.title && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.5, lineHeight: 1.3 }}>
|
||||||
|
{signal.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem' }}>
|
||||||
|
{signal.locationHint} · {signal.timeHorizonMonths} Mo.
|
||||||
|
</Typography>
|
||||||
|
{matchingProperties.length > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`${matchingProperties.length} Obj.`}
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 'auto', height: 16, fontSize: '0.6rem', fontWeight: 600, bgcolor: '#dcfce7', color: '#16a34a' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function LeadDetail({ lead }: { lead: MarketLead }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { signal, matchingProperties } = lead
|
||||||
|
const prob = Math.round(signal.probability * 100)
|
||||||
|
const color = probColor(signal.probability)
|
||||||
|
const sourceLabel = SOURCE_LABELS[signal.source.type] ?? signal.source.type
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
|
||||||
|
{signal.companyName ?? signal.locationHint}
|
||||||
|
</Typography>
|
||||||
|
{signal.title && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
|
{signal.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
|
<Chip label={`${prob}% Wahrscheinlichkeit`} size="small" sx={{ bgcolor: `${color}18`, color, fontWeight: 700 }} />
|
||||||
|
<Chip label={`${signal.timeHorizonMonths} Monate`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||||
|
{signal.areaSqmEstimate && (
|
||||||
|
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||||
|
)}
|
||||||
|
<Chip label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||||
|
<Chip label={sourceLabel} size="small" sx={{ bgcolor: '#f8fafc', color: '#64748b' }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
{/* AI summary */}
|
||||||
|
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
||||||
|
<Box sx={{ bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
|
<Sparkles size={13} color="#1d4ed8" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
|
||||||
|
KI-Analyse
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: '#1e3a8a', lineHeight: 1.6 }}>
|
||||||
|
{signal.aiSummary ?? signal.strategicInterpretation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Market indicators */}
|
||||||
|
{(signal.marketIndicators?.length ?? 0) > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||||
|
Erkannte Nachfragesignale
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{signal.marketIndicators!.map((ind, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<TrendingUp size={12} color="#1e3a5f" style={{ marginTop: 3, flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" sx={{ lineHeight: 1.5, color: '#1e293b' }}>{ind}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmed / unconfirmed facts */}
|
||||||
|
{((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||||
|
Faktencheck
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{(signal.confirmedFacts ?? []).map((f, i) => (
|
||||||
|
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={13} color="#16a34a" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 500 }}>{f}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{(signal.unconfirmedFacts ?? []).map((f, i) => (
|
||||||
|
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<AlertCircle size={13} color="#d97706" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 500 }}>{f}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Portfolio matches */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||||
|
Passende Objekte im Portfolio ({matchingProperties.length})
|
||||||
|
</Typography>
|
||||||
|
{matchingProperties.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
|
||||||
|
Kein passendes Objekt gefunden — manuelle Prüfung empfohlen
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{matchingProperties.slice(0, 3).map(p => (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
|
px: 1.25, py: 0.875, borderRadius: 1,
|
||||||
|
bgcolor: '#f0fdf4', border: '1px solid #bbf7d0',
|
||||||
|
cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 size={12} color="#16a34a" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#15803d', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#16a34a', flexShrink: 0 }}>
|
||||||
|
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{matchingProperties.length > 3 && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
|
||||||
|
+{matchingProperties.length - 3} weitere Objekte
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Bell size={14} />}
|
||||||
|
onClick={() => navigate('/supply/reminder-manager')}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
Reminder erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{signal.disclaimer && (
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||||||
|
{signal.disclaimer}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketIntelligence() {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const { data: leads, isLoading } = useMarketLeads()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId && leads.length > 0) setSelectedId(leads[0].signal.id)
|
||||||
|
}, [leads, selectedId])
|
||||||
|
|
||||||
|
const selectedLead = leads.find(l => l.signal.id === selectedId) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
|
{/* Left pane */}
|
||||||
|
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Markt-Signale</Typography>
|
||||||
|
<Chip label={isLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{isLoading
|
||||||
|
? [1, 2, 3, 4].map(i => (
|
||||||
|
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} />
|
||||||
|
<Skeleton variant="text" width="80%" height={14} />
|
||||||
|
<Skeleton variant="text" width="40%" height={12} />
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
: leads.map(lead => (
|
||||||
|
<LeadListItem
|
||||||
|
key={lead.signal.id}
|
||||||
|
lead={lead}
|
||||||
|
selected={lead.signal.id === selectedId}
|
||||||
|
onClick={() => setSelectedId(lead.signal.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right pane */}
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{selectedLead ? (
|
||||||
|
<LeadDetail lead={selectedLead} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<Typography variant="body2" color="text.disabled">Signal aus der Liste auswählen</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
BarChart2,
|
|
||||||
Briefcase,
|
|
||||||
Building2,
|
|
||||||
CheckCircle2,
|
|
||||||
FileText,
|
|
||||||
Newspaper,
|
|
||||||
Target,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
|
||||||
import { DecisionContextPanel } from '../../components/ui'
|
|
||||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
|
|
||||||
// ── Source meta ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
|
||||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={12} /> },
|
|
||||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={12} /> },
|
|
||||||
COMPANY_REPORT: { label: 'Geschäftsbericht',icon: <FileText size={12} /> },
|
|
||||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={12} /> },
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUALITY_META: Record<string, { label: string; color: string }> = {
|
|
||||||
HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
|
|
||||||
MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
|
|
||||||
LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
|
|
||||||
return probability >= 0.70 ? 'HIGH' : probability >= 0.50 ? 'MEDIUM' : 'LOW'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Property match chip ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function PropertyMatchRow({ p }: { p: Property }) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={() => navigate('/supply/properties')}
|
|
||||||
sx={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
|
||||||
px: 1.25, py: 0.75, borderRadius: 1,
|
|
||||||
bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
|
|
||||||
cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{p.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, flexShrink: 0 }}>
|
|
||||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lead card ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LeadCard({ lead }: { lead: MarketLead }) {
|
|
||||||
const { signal, matchingProperties } = lead
|
|
||||||
const probPct = Math.round(signal.probability * 100)
|
|
||||||
const qual = signalQuality(signal.probability)
|
|
||||||
const qualMeta = QUALITY_META[qual]
|
|
||||||
const sourceMeta = SOURCE_META[signal.source.type] ?? null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2.5, mb: 2,
|
|
||||||
borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
|
|
||||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
|
||||||
<Target size={18} color={DS_MARKET_SIGNAL.accent} />
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
|
|
||||||
{signal.companyName ?? signal.locationHint}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{signal.title}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
label="Market Signal"
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Key facts strip */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1.75 }}>
|
|
||||||
{signal.areaSqmEstimate && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Flächenbedarf</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Zeithorizont</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Wahrscheinlichkeit</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: qualMeta.color }}>{probPct}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Standort</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{signal.locationHint}</Typography>
|
|
||||||
</Box>
|
|
||||||
{sourceMeta && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Quelle</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
{sourceMeta.icon}
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.75 }} />
|
|
||||||
|
|
||||||
{/* Market indicators */}
|
|
||||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1.75 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
|
||||||
Erkannte Nachfragesignale
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{signal.marketIndicators.map((ind, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
|
||||||
<TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmed / unconfirmed facts */}
|
|
||||||
{((signal.confirmedFacts?.length ?? 0) > 0 || (signal.unconfirmedFacts?.length ?? 0) > 0) && (
|
|
||||||
<Box sx={{ mb: 1.75 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
|
||||||
{(signal.confirmedFacts ?? []).map((f, i) => (
|
|
||||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{(signal.unconfirmedFacts ?? []).map((f, i) => (
|
|
||||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Portfolio matches */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
|
||||||
Passende Objekte im Portfolio ({matchingProperties.length})
|
|
||||||
</Typography>
|
|
||||||
{matchingProperties.length === 0 ? (
|
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
|
|
||||||
Kein passendes Portfolioobjekt gefunden — manuell prüfen empfohlen
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{matchingProperties.slice(0, 3).map(p => (
|
|
||||||
<PropertyMatchRow key={p.id} p={p} />
|
|
||||||
))}
|
|
||||||
{matchingProperties.length > 3 && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
|
|
||||||
+{matchingProperties.length - 3} weitere Objekte
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Disclaimer */}
|
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BG.subtle}`, lineHeight: 1.4 }}>
|
|
||||||
{signal.disclaimer}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LeadSkeleton() {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2.5, mb: 2, borderLeft: `3px solid ${DS_BORDER.default}` }}>
|
|
||||||
<Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} />
|
|
||||||
<Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} />
|
|
||||||
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} />
|
|
||||||
<Skeleton variant="rectangular" height={64} sx={{ borderRadius: 1 }} />
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function MarketLeads() {
|
|
||||||
const { data: leads, isLoading } = useMarketLeads()
|
|
||||||
|
|
||||||
const leadsWithMatch = leads.filter(l => l.matchingProperties.length > 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<Users size={20} color={DS_MARKET_SIGNAL.accent} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{!isLoading && (
|
|
||||||
<Chip
|
|
||||||
label={`${leads.length} aktive Leads`}
|
|
||||||
size="small"
|
|
||||||
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
|
||||||
|
|
||||||
{/* Decision context */}
|
|
||||||
{!isLoading && leads.length > 0 && (
|
|
||||||
<DecisionContextPanel
|
|
||||||
decision="Welche Unternehmen könnten als Nachmieter für Ihre Objekte in Frage kommen?"
|
|
||||||
context={`${leads.length} erkannte Nachfragesignale · ${leadsWithMatch.length} mit passendem Portfolioobjekt`}
|
|
||||||
metrics={[
|
|
||||||
{ label: 'aktive Markt-Leads', value: leads.length, severity: 'positive' },
|
|
||||||
...(leadsWithMatch.length > 0
|
|
||||||
? [{ label: 'mit passendem Objekt', value: leadsWithMatch.length, severity: 'positive' as const }]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
risks={leads.length - leadsWithMatch.length > 0
|
|
||||||
? [`${leads.length - leadsWithMatch.length} Leads ohne direkten Portfolio-Match — Leerstandsanalyse empfohlen`]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
actions={[]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<LeadSkeleton />
|
|
||||||
<LeadSkeleton />
|
|
||||||
<LeadSkeleton />
|
|
||||||
</>
|
|
||||||
) : leads.length === 0 ? (
|
|
||||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
|
||||||
<Target size={32} color={DS_TEXT.disabled} style={{ marginBottom: 12 }} />
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography>
|
|
||||||
<Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
leads.map(lead => <LeadCard key={lead.signal.id} lead={lead} />)
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user