feat: Marktchancen — source links, KI-Kontaktdaten, portfolio match, Anschreiben

- ExtractedContact domain type (ContactType, ContactConfidence) on FutureSignal
- Mock extractedContacts for all 6 DEMAND signals (website, email, phone/linkedin)
- Source URL row with external link button
- KI-Kontaktdaten section: clickable links, confidence dots, purple accent
- PropertyMatchCard: area fit % chip (green/yellow/red), inline Anschreiben toggle
- buildAnschreiben() pre-filled German outreach template, editable + copy button
- Portfolio match section groups properties by fit quality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 23:32:42 +02:00
parent d8ff380d6e
commit 63909c8caf
3 changed files with 349 additions and 60 deletions
+13
View File
@@ -1,5 +1,15 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums' import type { SignalType, RiskLevel, ReviewStatus } from './enums'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
export interface ExtractedContact {
type: ContactType
value: string
confidence: ContactConfidence
label?: string
}
export interface SignalSource { export interface SignalSource {
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT' type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
url?: string url?: string
@@ -58,4 +68,7 @@ export interface FutureSignal {
// Unit-level reference: if set, this signal is about a specific rentable unit // Unit-level reference: if set, this signal is about a specific rentable unit
unitId?: string unitId?: string
// KI-extracted contact information from web crawler sources
extractedContacts?: ExtractedContact[]
} }
+35
View File
@@ -461,6 +461,12 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'DataCloud Systems AG befindet sich in einer klaren Wachstumsphase mit Fokus auf Zürich-West. Unternehmen mit diesem Wachstumsmuster sucht typischerweise 500700 m² Bürofläche mit Ausbauoptionen.', strategicInterpretation: 'DataCloud Systems AG befindet sich in einer klaren Wachstumsphase mit Fokus auf Zürich-West. Unternehmen mit diesem Wachstumsmuster sucht typischerweise 500700 m² Bürofläche mit Ausbauoptionen.',
confirmedFacts: ['Region Zürich-West aus Inseraten bestätigt', 'Flächentyp Büro plausibel', 'Wachstumstrend seit 6 Monaten vorhanden'], confirmedFacts: ['Region Zürich-West aus Inseraten bestätigt', 'Flächentyp Büro plausibel', 'Wachstumstrend seit 6 Monaten vorhanden'],
unconfirmedFacts: ['Exakter Flächenbedarf', 'Ob interner Ausbau am aktuellen Standort geplant', 'Zeitplan für die Entscheidung'], unconfirmedFacts: ['Exakter Flächenbedarf', 'Ob interner Ausbau am aktuellen Standort geplant', 'Zeitplan für die Entscheidung'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://datacloud-systems.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'EMAIL', value: 'info@datacloud-systems.ch', confidence: 'MEDIUM', label: 'Allgemeine Anfragen (geschätzt)' },
{ type: 'LINKEDIN', value: 'linkedin.com/company/datacloud-systems-ag', confidence: 'HIGH', label: 'LinkedIn' },
{ type: 'CONTACT_PERSON', value: 'Mark Weber (COO)', confidence: 'MEDIUM', label: 'Aus Stelleninserat-Signatur' },
],
}, },
// signal-004: Novabio Pharma AG — Laborexpansion Basel Allschwil // signal-004: Novabio Pharma AG — Laborexpansion Basel Allschwil
@@ -500,6 +506,12 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'Novabio Pharma AG kommuniziert Expansion öffentlich — Flächensuche sehr wahrscheinlich aktiv. Fokus auf Laborpersonal deutet auf kombinierten Lab-/Bürobedarf von 600800 m² hin.', strategicInterpretation: 'Novabio Pharma AG kommuniziert Expansion öffentlich — Flächensuche sehr wahrscheinlich aktiv. Fokus auf Laborpersonal deutet auf kombinierten Lab-/Bürobedarf von 600800 m² hin.',
confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'], confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'],
unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'], unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://novabio.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Dr. Anna Hoffmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht 2024' },
{ type: 'EMAIL', value: 'expansion@novabio.ch', confidence: 'LOW', label: 'Spekulativ — nicht bestätigt' },
{ type: 'PHONE', value: '+41 61 200 34 56', confidence: 'MEDIUM', label: 'Hauptnummer laut Website' },
],
}, },
// signal-007: SwissCart E-Commerce GmbH — Logistikexpansion Zug // signal-007: SwissCart E-Commerce GmbH — Logistikexpansion Zug
@@ -539,6 +551,12 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'Massiver Personalaufbau im Logistikbereich deutet auf konkreten Flächenbedarf hin. Zug als Steuerkanton bleibt attraktiver Firmensitz — kein Standortwechsel erwartet, nur Erweiterung.', strategicInterpretation: 'Massiver Personalaufbau im Logistikbereich deutet auf konkreten Flächenbedarf hin. Zug als Steuerkanton bleibt attraktiver Firmensitz — kein Standortwechsel erwartet, nur Erweiterung.',
confirmedFacts: ['Region Zug aus Inseraten bestätigt', 'Flächentyp Logistik/Lager aus Stellenprofilen ableitbar', 'Wachstumstrend seit Q3 2024 sichtbar'], confirmedFacts: ['Region Zug aus Inseraten bestätigt', 'Flächentyp Logistik/Lager aus Stellenprofilen ableitbar', 'Wachstumstrend seit Q3 2024 sichtbar'],
unconfirmedFacts: ['Ob zusätzliche Bürofläche benötigt', 'Exakter Flächenbedarf', 'Bevorzugte Strassenlage (Anlieferung)'], unconfirmedFacts: ['Ob zusätzliche Bürofläche benötigt', 'Exakter Flächenbedarf', 'Bevorzugte Strassenlage (Anlieferung)'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://swisscart.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'EMAIL', value: 'logistics@swisscart.ch', confidence: 'MEDIUM', label: 'Logistik-Kontakt (aus Inserat)' },
{ type: 'PHONE', value: '+41 41 500 12 34', confidence: 'MEDIUM', label: 'Aus Stelleninserat' },
{ type: 'CONTACT_PERSON', value: 'Sandra Brun (Head of Logistics)', confidence: 'HIGH', label: 'Aus Stelleninserat-Signatur' },
],
}, },
// signal-010: Ostschweiz Digital AG — Büroexpansion St. Gallen // signal-010: Ostschweiz Digital AG — Büroexpansion St. Gallen
@@ -578,6 +596,12 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'Kontinuierliches Teamwachstum mit Fokus auf St. Gallen deutet auf organische Expansion hin. Erweiterungsfläche am bisherigen Standort oder Umzug in grössere Einheit möglich.', strategicInterpretation: 'Kontinuierliches Teamwachstum mit Fokus auf St. Gallen deutet auf organische Expansion hin. Erweiterungsfläche am bisherigen Standort oder Umzug in grössere Einheit möglich.',
confirmedFacts: ['Region St. Gallen aus Inseraten bestätigt', 'Wachstumstrend seit Q3 2024 nachweisbar', 'Flächentyp Büro aus Stellenprofilen ableitbar'], confirmedFacts: ['Region St. Gallen aus Inseraten bestätigt', 'Wachstumstrend seit Q3 2024 nachweisbar', 'Flächentyp Büro aus Stellenprofilen ableitbar'],
unconfirmedFacts: ['Ob aktueller Standort Riethüsli oder neue Lage', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'], unconfirmedFacts: ['Ob aktueller Standort Riethüsli oder neue Lage', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://ostschweiz-digital.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'EMAIL', value: 'office@ostschweiz-digital.ch', confidence: 'MEDIUM', label: 'Office-Kontakt' },
{ type: 'CONTACT_PERSON', value: 'Reto Huber (CEO)', confidence: 'HIGH', label: 'LinkedIn-Profil' },
{ type: 'LINKEDIN', value: 'linkedin.com/in/reto-huber-ostschweiz', confidence: 'HIGH', label: 'CEO LinkedIn' },
],
}, },
// signal-013: Geneva Finance Partners SA — Büroexpansion Genf // signal-013: Geneva Finance Partners SA — Büroexpansion Genf
@@ -617,6 +641,11 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'Öffentlich kommunizierte Expansion mit klarem Zielhorizont 2026. La Praille bietet kostengünstigere Büroflächen als Genf Innenstadt bei guter ÖV-Anbindung. Flächensuche dürfte aktiv beginnen.', strategicInterpretation: 'Öffentlich kommunizierte Expansion mit klarem Zielhorizont 2026. La Praille bietet kostengünstigere Büroflächen als Genf Innenstadt bei guter ÖV-Anbindung. Flächensuche dürfte aktiv beginnen.',
confirmedFacts: ['Expansion im Jahresbericht kommuniziert', 'Teamwachstum auf 40 FTE bis 2026 bestätigt', 'Inserate für Genf aktiv'], confirmedFacts: ['Expansion im Jahresbericht kommuniziert', 'Teamwachstum auf 40 FTE bis 2026 bestätigt', 'Inserate für Genf aktiv'],
unconfirmedFacts: ['Ob La Praille oder andere Lage bevorzugt', 'Exakter Flächenbedarf', 'Ob Repräsentationsbüros benötigt'], unconfirmedFacts: ['Ob La Praille oder andere Lage bevorzugt', 'Exakter Flächenbedarf', 'Ob Repräsentationsbüros benötigt'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://genevafinancepartners.com', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Pierre Dumont (Directeur Général)', confidence: 'HIGH', label: 'Aus Jahresbericht 2024' },
{ type: 'EMAIL', value: 'contact@genevafinancepartners.com', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
],
}, },
// signal-015: BernTech Innovation AG — Büroexpansion Bern Breitenrain // signal-015: BernTech Innovation AG — Büroexpansion Bern Breitenrain
@@ -656,5 +685,11 @@ export const mockFutureSignals: FutureSignal[] = [
strategicInterpretation: 'Frisch finanziertes Wachstumsunternehmen mit aktivem Stellenaufbau und gesichertem Budget. VC-Finanzierung als starkes Signal für konkreten Flächenbedarf. Breitenrain als Tech-Standort in Bern etabliert.', strategicInterpretation: 'Frisch finanziertes Wachstumsunternehmen mit aktivem Stellenaufbau und gesichertem Budget. VC-Finanzierung als starkes Signal für konkreten Flächenbedarf. Breitenrain als Tech-Standort in Bern etabliert.',
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'], confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'],
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'5002\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'], unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'5002\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://berntech.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'EMAIL', value: 'hello@berntech.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
{ type: 'CONTACT_PERSON', value: 'Stefan Leuenberger (CEO)', confidence: 'HIGH', label: 'LinkedIn + Pressebericht' },
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
],
}, },
] ]
+300 -59
View File
@@ -1,9 +1,20 @@
import { memo, useState, useEffect } from 'react' import { memo, useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { Box, Chip, Divider, Skeleton, Typography, Button } from '@mui/material' import {
import { AlertCircle, Bell, Building2, CheckCircle2, Sparkles, TrendingUp } from 'lucide-react' Alert, Box, Button, Chip, Divider, IconButton,
Skeleton, TextField, Tooltip, Typography,
} from '@mui/material'
import {
AlertCircle, Bell, Building2, CheckCircle2, Copy,
ExternalLink, Globe, Linkedin, Mail, MapPin, Phone,
Ruler, Sparkles, TrendingUp, User,
} from 'lucide-react'
import { useMarketLeads } from '../../hooks/useMarketLeads' import { useMarketLeads } from '../../hooks/useMarketLeads'
import type { MarketLead } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads'
import type { Property } from '../../domain/property'
import type { ExtractedContact } from '../../domain/futureSignal'
// ── Constants ────────────────────────────────────────────────────────────────
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
JOB_POSTING: 'Stelleninserate', JOB_POSTING: 'Stelleninserate',
@@ -15,12 +26,58 @@ const SOURCE_LABELS: Record<string, string> = {
LEASE_CONTRACT: 'Mietvertrag', LEASE_CONTRACT: 'Mietvertrag',
} }
const CONTACT_ICONS: Record<string, React.ReactNode> = {
EMAIL: <Mail size={13} />,
PHONE: <Phone size={13} />,
WEBSITE: <Globe size={13} />,
LINKEDIN: <Linkedin size={13} />,
CONTACT_PERSON: <User size={13} />,
}
const CONF_COLOR: Record<string, string> = {
HIGH: '#16a34a',
MEDIUM: '#d97706',
LOW: '#94a3b8',
}
const CONF_LABEL: Record<string, string> = {
HIGH: 'Bestätigt',
MEDIUM: 'Wahrscheinlich',
LOW: 'Spekulativ',
}
function probColor(p: number): string { function probColor(p: number): string {
if (p >= 0.70) return '#1a7a4a' if (p >= 0.70) return '#1a7a4a'
if (p >= 0.50) return '#d97706' if (p >= 0.50) return '#d97706'
return '#dc2626' return '#dc2626'
} }
function areaFitPct(propArea: number, signalArea: number): number {
return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100))
}
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')}`
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({ const LeadListItem = memo(function LeadListItem({
lead, selected, onClick, lead, selected, onClick,
}: { }: {
@@ -76,38 +133,223 @@ const LeadListItem = memo(function LeadListItem({
) )
}) })
function LeadDetail({ lead }: { lead: MarketLead }) { // ── 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 (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, py: 0.75, borderBottom: '1px solid #f8fafc' }}>
<Box sx={{ color: confColor, mt: 0.125, flexShrink: 0 }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{isClickable && href ? (
<Box
component="a"
href={href}
target="_blank"
rel="noopener noreferrer"
sx={{ color: '#1e3a5f', fontWeight: 500, fontSize: '0.8125rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' }, display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{contact.value}
<ExternalLink size={11} />
</Box>
) : (
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem', color: '#0f172a' }}>
{contact.value}
</Typography>
)}
{contact.label && (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
{contact.label}
</Typography>
)}
</Box>
<Tooltip title={CONF_LABEL[contact.confidence]}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: confColor, flexShrink: 0, mt: 0.5 }} />
</Tooltip>
</Box>
)
}
// ── Property match card with Anschreiben composer ────────────────────────────
function PropertyMatchCard({
p, signal, companyName,
}: {
p: Property
signal: MarketLead['signal']
companyName: string
}) {
const navigate = useNavigate() 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 (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', mb: 1 }}>
{/* Property header */}
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
px: 1.75, py: 1.25,
bgcolor: '#f8fafc',
cursor: 'pointer',
'&:hover': { bgcolor: '#f1f5f9' },
}}
onClick={() => navigate('/supply/properties')}
>
<Building2 size={14} color="#1e3a5f" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J
</Typography>
</Box>
{fit !== null && (
<Chip
label={`${fit}% Flächenmatch`}
size="small"
sx={{
height: 20, fontSize: '0.65rem', fontWeight: 700, flexShrink: 0,
bgcolor: fit >= 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2',
color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626',
}}
/>
)}
</Box>
{/* Anschreiben toggle */}
<Box sx={{ px: 1.75, py: 1, display: 'flex', alignItems: 'center', gap: 1, borderTop: '1px solid #f1f5f9' }}>
<Button
size="small"
variant={open ? 'contained' : 'outlined'}
onClick={() => setOpen(v => !v)}
sx={{
textTransform: 'none', fontSize: '0.75rem',
bgcolor: open ? '#1e3a5f' : undefined,
borderColor: open ? '#1e3a5f' : undefined,
'&:hover': { bgcolor: open ? '#162d4a' : undefined },
}}
>
{open ? 'Schliessen' : 'Anschreiben erstellen'}
</Button>
<Button
size="small"
variant="text"
onClick={() => navigate('/supply/reminder-manager')}
startIcon={<Bell size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b' }}
>
Reminder
</Button>
</Box>
{/* Anschreiben composer */}
{open && (
<Box sx={{ px: 1.75, pb: 1.75, pt: 0.5, borderTop: '1px solid #f1f5f9', bgcolor: '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
Anschreiben-Entwurf
</Typography>
<Button
size="small"
startIcon={copied ? <CheckCircle2 size={13} /> : <Copy size={13} />}
onClick={handleCopy}
sx={{
textTransform: 'none', fontSize: '0.72rem',
color: copied ? '#16a34a' : '#1e3a5f',
}}
>
{copied ? 'Kopiert!' : 'Kopieren'}
</Button>
</Box>
<TextField
multiline
minRows={8}
fullWidth
value={draft}
onChange={e => setDraft(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' },
'& textarea': { lineHeight: 1.6 },
}}
/>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mt: 0.75 }}>
Text bearbeitbar dann kopieren und per E-Mail versenden
</Typography>
</Box>
)}
</Box>
)
}
// ── Right pane detail ────────────────────────────────────────────────────────
function LeadDetail({ lead }: { lead: MarketLead }) {
const { signal, matchingProperties } = lead const { signal, matchingProperties } = lead
const prob = Math.round(signal.probability * 100) const prob = Math.round(signal.probability * 100)
const color = probColor(signal.probability) const color = probColor(signal.probability)
const sourceLabel = SOURCE_LABELS[signal.source.type] ?? signal.source.type const sourceLabel = SOURCE_LABELS[signal.source.type] ?? signal.source.type
const companyName = signal.companyName ?? signal.locationHint
return ( return (
<Box sx={{ height: '100%', overflowY: 'auto' }}> <Box sx={{ height: '100%', overflowY: 'auto' }}>
{/* Header */} {/* Header */}
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}> <Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, mb: 0.5 }}>
{signal.companyName ?? signal.locationHint} <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{companyName}
</Typography> </Typography>
<Chip label={`${prob}% Wahrscheinlichkeit`} size="small" sx={{ bgcolor: `${color}18`, color, fontWeight: 700, flexShrink: 0 }} />
</Box>
{signal.title && ( {signal.title && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{signal.title} {signal.title}
</Typography> </Typography>
)} )}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}> {/* Stats row */}
<Chip label={`${prob}% Wahrscheinlichkeit`} size="small" sx={{ bgcolor: `${color}18`, color, fontWeight: 700 }} /> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
<Chip label={`${signal.timeHorizonMonths} Monate`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} /> <Chip icon={<MapPin size={11} />} label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
{signal.areaSqmEstimate && ( <Chip icon={<Ruler size={11} />} label={signal.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')}` : 'Fläche unbekannt'} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')}`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} /> <Chip label={`${signal.timeHorizonMonths} Monate`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
</Box>
{/* Source link */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.75, px: 1.25, bgcolor: '#f8fafc', borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="caption" sx={{ color: '#64748b', flexShrink: 0 }}>
Quelle: <strong>{sourceLabel}</strong>
{signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`}
</Typography>
{signal.source.url && (
<Box
component="a"
href={signal.source.url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto', color: '#1e3a5f', fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
Quelle öffnen <ExternalLink size={11} />
</Box>
)} )}
<Chip label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
<Chip label={sourceLabel} size="small" sx={{ bgcolor: '#f8fafc', color: '#64748b' }} />
</Box> </Box>
</Box> </Box>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* AI summary */} {/* KI summary */}
{(signal.aiSummary ?? signal.strategicInterpretation) && ( {(signal.aiSummary ?? signal.strategicInterpretation) && (
<Box sx={{ bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, p: 2 }}> <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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
@@ -139,7 +381,7 @@ function LeadDetail({ lead }: { lead: MarketLead }) {
</Box> </Box>
)} )}
{/* Confirmed / unconfirmed facts */} {/* Facts */}
{((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && ( {((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && (
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}> <Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
@@ -164,57 +406,54 @@ function LeadDetail({ lead }: { lead: MarketLead }) {
<Divider /> <Divider />
{/* Portfolio matches */} {/* KI contact extraction */}
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
Passende Objekte im Portfolio ({matchingProperties.length}) <Sparkles size={13} color="#7c3aed" />
</Typography> <Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
{matchingProperties.length === 0 ? ( KI-Kontaktdaten
<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> </Typography>
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{(['HIGH','MEDIUM','LOW'] as const).map(c => (
<Box key={c} sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: CONF_COLOR[c] }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.6rem' }}>{CONF_LABEL[c]}</Typography>
</Box> </Box>
))} ))}
{matchingProperties.length > 3 && ( </Box>
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}> </Box>
+{matchingProperties.length - 3} weitere Objekte {(signal.extractedContacts?.length ?? 0) === 0 ? (
</Typography> <Alert severity="info" sx={{ fontSize: '0.8rem', py: 0.5 }}>
)} Keine Kontaktdaten aus Crawler-Quellen extrahiert manuelle Recherche empfohlen
</Alert>
) : (
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #ede9fe', borderRadius: 1.5, px: 1.75, pt: 0.5, pb: 0.25 }}>
{signal.extractedContacts!.map((c, i) => <ContactRow key={i} contact={c} />)}
</Box> </Box>
)} )}
</Box> </Box>
{/* Action */} <Divider />
{/* Portfolio matches */}
<Box> <Box>
<Button <Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
variant="outlined" Passende Objekte im Portfolio ({matchingProperties.length})
size="small" </Typography>
startIcon={<Bell size={14} />} {matchingProperties.length === 0 ? (
onClick={() => navigate('/supply/reminder-manager')} <Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
sx={{ textTransform: 'none', fontSize: '0.8rem' }} Kein passendes Portfolioobjekt gefunden manuelle Prüfung empfohlen
> </Alert>
Reminder erstellen ) : (
</Button> matchingProperties.map(p => (
<PropertyMatchCard
key={p.id}
p={p}
signal={signal}
companyName={companyName}
/>
))
)}
</Box> </Box>
{signal.disclaimer && ( {signal.disclaimer && (
@@ -227,6 +466,8 @@ function LeadDetail({ lead }: { lead: MarketLead }) {
) )
} }
// ── Page ─────────────────────────────────────────────────────────────────────
export default function MarketIntelligence() { export default function MarketIntelligence() {
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const { data: leads, isLoading } = useMarketLeads() const { data: leads, isLoading } = useMarketLeads()
@@ -242,7 +483,7 @@ export default function MarketIntelligence() {
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}> <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="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Erkannte Unternehmen mit aktivem Flächenbedarf potenzielle Mieter für Ihr Portfolio KI-erkannte Unternehmen mit Flächenbedarf · automatisch aus Web-Quellen · mit Portfolio-Match und Anschreiben
</Typography> </Typography>
</Box> </Box>
@@ -250,7 +491,7 @@ export default function MarketIntelligence() {
{/* Left pane */} {/* Left pane */}
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <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 }}> <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> <Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography>
<Chip label={isLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} /> <Chip label={isLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} />
</Box> </Box>
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>