b0ad6643ff
EXPANSION-Signale (Unternehmen sucht Fläche) sind Nachfragesignale und gehören nicht in den Demand-Feed. Neue Architektur: - signalDirection: 'SUPPLY' | 'DEMAND' auf FutureSignal-Domain - SUPPLY-Signale: POSSIBLE_MOVE_OUT / CONSTRUCTION_PROJECT (Crawler) und LEASE_EXPIRY+isVerified (Verwaltung ERP) → Demand-Feed wie bisher - DEMAND-Signale: EXPANSION → aus Demand-Feed gefiltert - Neues useMarketLeads-Hook + /supply/market-leads Seite für Verwaltung: zeigt EXPANSION-Signale als potenzielle Mieter mit Portfolio-Matching - Alle 15 Signale in futureSignals.ts inhaltlich bereinigt und mit korrekter signalDirection versehen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import {
|
|
Box,
|
|
Chip,
|
|
CircularProgress,
|
|
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 { 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: '#1a7a4a' },
|
|
MEDIUM: { label: 'Mittlere Signalqualität', color: '#d97706' },
|
|
LOW: { label: 'Niedrige Signalqualität', color: '#c0392b' },
|
|
}
|
|
|
|
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: '#f0fdf4', border: '1px solid #bbf7d0',
|
|
cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' },
|
|
}}
|
|
>
|
|
<Building2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{p.title}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: '#1a7a4a', 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 #1d4ed8',
|
|
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="#1d4ed8" />
|
|
</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: '#eff6ff', color: '#1d4ed8', 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: '#475569', 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="#1d4ed8" style={{ marginTop: 3, flexShrink: 0 }} />
|
|
<Typography variant="body2" sx={{ color: '#374151', 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="#1a7a4a" style={{ flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: '#1a7a4a', 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="#d97706" style={{ flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{f}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
<Divider sx={{ mb: 1.5 }} />
|
|
|
|
{/* Portfolio matches */}
|
|
<Box>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', 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 #f1f5f9', lineHeight: 1.4 }}>
|
|
{signal.disclaimer}
|
|
</Typography>
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
|
|
|
function LeadSkeleton() {
|
|
return (
|
|
<Paper sx={{ p: 2.5, mb: 2, borderLeft: '3px solid #e2e8f0' }}>
|
|
<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 #e2e8f0', flexShrink: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Users size={20} color="#1d4ed8" />
|
|
<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: '#eff6ff', color: '#1d4ed8', 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' }}>
|
|
<CircularProgress size={24} sx={{ mb: 2, color: '#94a3b8' }} />
|
|
<Typography variant="body1" color="text.secondary">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>
|
|
)
|
|
}
|