import { memo, useState, useEffect, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router' import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Select as MuiSelect, Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography, } from '@mui/material' import { AlertCircle, Bell, Building2, CheckCircle2, Copy, ExternalLink, Globe, Linkedin, Lock, Mail, MapPin, Phone, Plus, Ruler, Search, Sparkles, TrendingUp, User, Users, } from 'lucide-react' import { useMarketLeads } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads' import { useMarktHinweise, useCreateMarktHinweis } from '../../hooks/useMarktHinweise' import type { MarktHinweis, HinweisVisibility, HinweisDirection, HinweisQuelle, CreateMarktHinweisInput } from '../../domain/marktHinweis' import { useProperties } from '../../hooks/useProperties' import type { Property } from '../../domain/property' import type { ExtractedContact } from '../../domain/futureSignal' // ── Constants ──────────────────────────────────────────────────────────────── const OWN_VERWALTUNG_ID = 'v-001' const SOURCE_LABELS: Record = { JOB_POSTING: 'Stelleninserate', PRESS: 'Pressebericht', COMPANY_REPORT: 'Geschäftsbericht', MARKET_DATA: 'Marktdaten', MANUAL: 'Manuell', CONSTRUCTION_PERMIT: 'Baugenehmigung', LEASE_CONTRACT: 'Mietvertrag', } const QUELLE_LABELS: Record = { NETZWERKEVENT: 'Netzwerkevent', TELEFONAT: 'Telefonat', BESICHTIGUNG: 'Besichtigung', MESSE: 'Messe', EMAIL: 'E-Mail', SONSTIGES: 'Sonstiges', } const ASSET_TYPE_LABELS: Record = { OFFICE: 'Büro', RETAIL: 'Einzelhandel', LIGHT_INDUSTRIAL: 'Gewerbe', LOGISTICS: 'Logistik', PRODUCTION: 'Produktion', MIXED: 'Gemischt', } const CONTACT_ICONS: Record = { EMAIL: , PHONE: , WEBSITE: , LINKEDIN: , CONTACT_PERSON: , } const CONF_COLOR: Record = { HIGH: '#16a34a', MEDIUM: '#d97706', LOW: '#94a3b8', } const CONF_LABEL: Record = { HIGH: 'Bestätigt', MEDIUM: 'Wahrscheinlich', LOW: 'Spekulativ', } function probColor(p: number): string { if (p >= 0.70) return '#1a7a4a' if (p >= 0.50) return '#d97706' return '#dc2626' } function areaFitPct(propArea: number, signalArea: number): number { return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100)) } function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('de-CH') } function buildAnschreiben(company: string, location: string, p: Property, areaSqmEstimate?: number): string { const areaLine = areaSqmEstimate ? `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m² (Sie suchen ca. ${areaSqmEstimate.toLocaleString('de-CH')} m²)` : `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m²` return `Sehr geehrte Damen und Herren, wir haben erkannt, dass ${company} nach Gewerbeflächen im Raum ${location} sucht. Gerne möchten wir Ihnen eine passende Option aus unserem Portfolio vorstellen: Objekt: ${p.title} Lage: ${p.location.city} ${areaLine} Mietpreis: CHF ${p.rentPricePerSqm.toLocaleString('de-CH')} / m² / Jahr Wir würden uns freuen, Ihnen das Objekt in einer unverbindlichen Besichtigung vorzustellen und Ihre konkreten Anforderungen zu besprechen. Mit freundlichen Grüssen Wincasa AG Immobilienverwaltung` } // ── Left pane list item ────────────────────────────────────────────────────── 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 ( {signal.companyName ?? signal.locationHint} {signal.title && ( {signal.title} )} {signal.locationHint} · {signal.timeHorizonMonths} Mo. {matchingProperties.length > 0 && ( )} ) }) // ── Contact row ────────────────────────────────────────────────────────────── function ContactRow({ contact }: { contact: ExtractedContact }) { const icon = CONTACT_ICONS[contact.type] const confColor = CONF_COLOR[contact.confidence] const isClickable = contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' || contact.type === 'EMAIL' const href = contact.type === 'EMAIL' ? `mailto:${contact.value}` : contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' ? `https://${contact.value.replace(/^https?:\/\//, '')}` : undefined return ( {icon} {isClickable && href ? ( {contact.value} ) : ( {contact.value} )} {contact.label && ( {contact.label} )} ) } // ── Property match card with Anschreiben composer ──────────────────────────── function PropertyMatchCard({ p, signal, companyName, }: { p: Property signal: MarketLead['signal'] companyName: string }) { const navigate = useNavigate() const [open, setOpen] = useState(false) const [copied, setCopied] = useState(false) const [draft, setDraft] = useState(() => buildAnschreiben(companyName, signal.locationHint, p, signal.areaSqmEstimate) ) const fit = signal.areaSqmEstimate ? areaFitPct(p.areaSqm, signal.areaSqmEstimate) : null const handleCopy = useCallback(() => { navigator.clipboard.writeText(draft) setCopied(true) setTimeout(() => setCopied(false), 2000) }, [draft]) return ( {/* Property header */} navigate('/supply/properties')} > {p.title} {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J {fit !== null && ( = 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2', color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626', }} /> )} {/* Anschreiben toggle */} {/* Anschreiben composer */} {open && ( Anschreiben-Entwurf setDraft(e.target.value)} sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' }, '& textarea': { lineHeight: 1.6 }, }} /> Text bearbeitbar — dann kopieren und per E-Mail versenden )} ) } // ── Right pane detail ──────────────────────────────────────────────────────── function LeadDetail({ lead }: { lead: MarketLead }) { 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 const companyName = signal.companyName ?? signal.locationHint return ( {/* Header */} {companyName} {signal.title && ( {signal.title} )} {/* Stats row */} } label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> } label={signal.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²` : 'Fläche unbekannt'} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> {/* Source link */} Quelle: {sourceLabel} {signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`} {signal.source.url && ( Quelle öffnen )} {/* KI summary */} {(signal.aiSummary ?? signal.strategicInterpretation) && ( KI-Analyse {signal.aiSummary ?? signal.strategicInterpretation} )} {/* Market indicators */} {(signal.marketIndicators?.length ?? 0) > 0 && ( Erkannte Nachfragesignale {signal.marketIndicators!.map((ind, i) => ( {ind} ))} )} {/* Facts */} {((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && ( Faktencheck {(signal.confirmedFacts ?? []).map((f, i) => ( {f} ))} {(signal.unconfirmedFacts ?? []).map((f, i) => ( {f} ))} )} {/* KI contact extraction */} KI-Kontaktdaten {(['HIGH','MEDIUM','LOW'] as const).map(c => ( {CONF_LABEL[c]} ))} {(signal.extractedContacts?.length ?? 0) === 0 ? ( Keine Kontaktdaten aus Crawler-Quellen extrahiert — manuelle Recherche empfohlen ) : ( {signal.extractedContacts!.map((c, i) => )} )} {/* Portfolio matches */} Passende Objekte im Portfolio ({matchingProperties.length}) {matchingProperties.length === 0 ? ( Kein passendes Portfolioobjekt gefunden — manuelle Prüfung empfohlen ) : ( matchingProperties.map(p => ( )) )} {signal.disclaimer && ( {signal.disclaimer} )} ) } // ── Netzwerk: HinweisPropertyCard ──────────────────────────────────────────── const HinweisPropertyCard = memo(function HinweisPropertyCard({ p, hinweis, }: { p: Property hinweis: MarktHinweis }) { const [open, setOpen] = useState(false) const [copied, setCopied] = useState(false) const companyName = hinweis.isAnonymized ? 'Interessent' : (hinweis.companyName ?? 'Interessent') const areaEstimate = hinweis.areaSqmMax ?? hinweis.areaSqmMin const [draft, setDraft] = useState(() => buildAnschreiben(companyName, hinweis.locationHint, p, areaEstimate) ) const fit = areaEstimate != null ? areaFitPct(p.areaSqm, areaEstimate) : null const handleCopy = useCallback(() => { navigator.clipboard.writeText(draft) setCopied(true) setTimeout(() => setCopied(false), 2000) }, [draft]) return ( {/* Property header */} {p.title} {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J {fit !== null && ( = 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2', color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626', }} /> )} {/* Anschreiben toggle */} {/* Anschreiben composer */} {open && ( Anschreiben-Entwurf setDraft(e.target.value)} sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' }, '& textarea': { lineHeight: 1.6 }, }} /> Text bearbeitbar — dann kopieren und per E-Mail versenden )} ) }) // ── Netzwerk: HinweisListItem ──────────────────────────────────────────────── const HinweisListItem = memo(function HinweisListItem({ hinweis, selected, onClick, }: { hinweis: MarktHinweis selected: boolean onClick: () => void }) { const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}` const areaStr = useMemo(() => { if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` } if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²` if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²` return '' }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) const visibilityBadge = useMemo(() => { if (hinweis.visibility === 'INTERN') { return } if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) { return } return }, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName]) return ( {hinweis.direction === 'SUCHE' ? : } {displayName} {visibilityBadge} {hinweis.locationHint}{areaStr} {hinweis.direction === 'SUCHE' ? : } ) }) // ── Netzwerk: HinweisDetail ────────────────────────────────────────────────── function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) { const { data: properties = [] } = useProperties() const matchingProperties = useMemo( () => properties.filter(p => p.assetType === hinweis.assetType).slice(0, 3), [properties, hinweis.assetType], ) const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonym' const areaStr = useMemo(() => { if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { return `${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` } if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax} m²` if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin} m²` return null }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) return ( {/* Header */} {displayName} {/* Badge row */} {hinweis.direction === 'SUCHE' ? : } {hinweis.visibility === 'INTERN' ? : hinweis.verwaltungId === OWN_VERWALTUNG_ID ? : } {/* Stat chips */} } label={hinweis.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> {areaStr && } label={areaStr} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />} {/* Quelle row */} Quelle: {QUELLE_LABELS[hinweis.quelle] ?? hinweis.quelle} {' · '}{hinweis.createdBy} {' · '}{formatDate(hinweis.createdAt)} {/* Body */} {hinweis.note && ( Notiz {hinweis.note} )} {hinweis.direction === 'SUCHE' ? ( Passende Objekte im Portfolio ({matchingProperties.length}) {matchingProperties.length === 0 ? ( Kein passendes Portfolioobjekt gefunden ) : ( matchingProperties.map(p => ( )) )} ) : ( Verfügbarkeitssignal — wird auf der Plattform für andere Verwaltungen sichtbar gemacht )} ) } // ── Netzwerk: HinweisErfassenDialog ───────────────────────────────────────── function HinweisErfassenDialog({ open, onClose, onCreated, }: { open: boolean onClose: () => void onCreated: (id: string) => void }) { const mutation = useCreateMarktHinweis() const [direction, setDirection] = useState('SUCHE') const [assetType, setAssetType] = useState('') const [locationHint, setLocationHint] = useState('') const [areaSqmMin, setAreaSqmMin] = useState('') const [areaSqmMax, setAreaSqmMax] = useState('') const [companyName, setCompanyName] = useState('') const [isAnonymized, setIsAnonymized] = useState(false) const [quelle, setQuelle] = useState('NETZWERKEVENT') const [note, setNote] = useState('') const [visibility, setVisibility] = useState('INTERN') const isValid = assetType.trim() !== '' && locationHint.trim() !== '' function resetForm() { setDirection('SUCHE') setAssetType('') setLocationHint('') setAreaSqmMin('') setAreaSqmMax('') setCompanyName('') setIsAnonymized(false) setQuelle('NETZWERKEVENT') setNote('') setVisibility('INTERN') } function handleSave() { if (!isValid) return const input: CreateMarktHinweisInput = { direction, assetType: assetType as MarktHinweis['assetType'], locationHint, areaSqmMin: areaSqmMin ? Number(areaSqmMin) : undefined, areaSqmMax: areaSqmMax ? Number(areaSqmMax) : undefined, companyName: companyName.trim() || undefined, isAnonymized, quelle, note: note.trim() || undefined, visibility, status: 'OFFEN', } mutation.mutate(input, { onSuccess: (result) => { onCreated(result.id) resetForm() }, }) } function handleClose() { resetForm() onClose() } return ( Hinweis erfassen {/* Richtung */} Richtung { if (v) setDirection(v as HinweisDirection) }} size="small" sx={{ '& .MuiToggleButton-root': { textTransform: 'none', fontSize: '0.8rem' } }} > Jemand sucht Wird verfügbar {/* Asset-Typ */} Asset-Typ * setAssetType(e.target.value)} > Büro Einzelhandel Gewerbe Logistik Produktion {/* Stadt / Region */} setLocationHint(e.target.value)} /> {/* Fläche */} setAreaSqmMin(e.target.value)} sx={{ flex: 1 }} /> setAreaSqmMax(e.target.value)} sx={{ flex: 1 }} /> {/* Firma / Name */} setCompanyName(e.target.value)} /> {/* Quelle */} Quelle setQuelle(e.target.value as HinweisQuelle)} > {Object.entries(QUELLE_LABELS).map(([k, v]) => ( {v} ))} {/* Notiz */} setNote(e.target.value)} /> {/* Sichtbarkeit */} Sichtbarkeit setVisibility('INTERN')} sx={{ flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 0.5, border: visibility === 'INTERN' ? '2px solid #4338ca' : '1px solid #e2e8f0', bgcolor: visibility === 'INTERN' ? '#eef2ff' : 'white', }} > Intern Nur für Ihr Team sichtbar setVisibility('PLATTFORM')} sx={{ flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 0.5, border: visibility === 'PLATTFORM' ? '2px solid #7c3aed' : '1px solid #e2e8f0', bgcolor: visibility === 'PLATTFORM' ? '#faf5ff' : 'white', }} > Plattform Für alle Verwaltungen auf Property Match sichtbar {/* Anonymisieren (only for PLATTFORM) */} {visibility === 'PLATTFORM' && ( setIsAnonymized(e.target.checked)} size="small" /> } label={Firmenname anonymisieren} /> )} ) } // ── Page ───────────────────────────────────────────────────────────────────── export default function MarketIntelligence() { const [activeTab, setActiveTab] = useState(0) const [selectedSignalId, setSelectedSignalId] = useState(null) const [selectedHinweisId, setSelectedHinweisId] = useState(null) const [erfassenOpen, setErfassenOpen] = useState(false) const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL') const { data: leads, isLoading: leadsLoading } = useMarketLeads() const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise() // auto-select first signal useEffect(() => { if (!selectedSignalId && leads.length > 0) setSelectedSignalId(leads[0].signal.id) }, [leads, selectedSignalId]) // auto-select first hinweis useEffect(() => { if (!selectedHinweisId && hinweise.length > 0) setSelectedHinweisId(hinweise[0].id) }, [hinweise, selectedHinweisId]) const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null const filteredHinweise = useMemo(() => { if (hinweisFilter === 'ALL') return hinweise return hinweise.filter(h => h.visibility === hinweisFilter) }, [hinweise, hinweisFilter]) const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null return ( {/* Page header */} Marktchancen KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match {/* Tabs */} setActiveTab(v)} sx={{ borderBottom: '1px solid #e2e8f0', flexShrink: 0, bgcolor: 'white', '& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 }, }} > } iconPosition="start" label="KI-Signale" /> } iconPosition="start" label="Netzwerk" /> {/* Tab 0: KI-Signale */} {activeTab === 0 && ( {/* Left pane */} Erkannte Signale {leadsLoading ? [1, 2, 3, 4].map(i => ( )) : leads.map(lead => ( setSelectedSignalId(lead.signal.id)} /> ))} {/* Right pane */} {selectedLead ? ( ) : ( Signal aus der Liste auswählen )} )} {/* Tab 1: Netzwerk */} {activeTab === 1 && ( {/* Left pane */} Netzwerk-Hinweise {/* Filter chips */} {(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => ( setHinweisFilter(f)} sx={{ height: 22, fontSize: '0.68rem', cursor: 'pointer', bgcolor: hinweisFilter === f ? '#1e3a5f' : '#f1f5f9', color: hinweisFilter === f ? 'white' : '#475569', }} /> ))} {/* List */} {hinweiseLoading ? [1, 2, 3].map(i => ( )) : filteredHinweise.map(h => ( setSelectedHinweisId(h.id)} /> )) } {/* Right pane */} {selectedHinweis ? ( ) : ( Hinweis aus der Liste auswählen )} )} {/* Dialog */} setErfassenOpen(false)} onCreated={(id) => { setSelectedHinweisId(id) setErfassenOpen(false) setActiveTab(1) }} /> ) }