diff --git a/src/domain/futureSignal.ts b/src/domain/futureSignal.ts index ad896f3..68f4ab5 100644 --- a/src/domain/futureSignal.ts +++ b/src/domain/futureSignal.ts @@ -1,5 +1,15 @@ 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 { type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT' url?: string @@ -58,4 +68,7 @@ export interface FutureSignal { // Unit-level reference: if set, this signal is about a specific rentable unit unitId?: string + + // KI-extracted contact information from web crawler sources + extractedContacts?: ExtractedContact[] } diff --git a/src/mock-data/futureSignals.ts b/src/mock-data/futureSignals.ts index fbbbbf1..5db15bb 100644 --- a/src/mock-data/futureSignals.ts +++ b/src/mock-data/futureSignals.ts @@ -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 500–700 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'], 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 @@ -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 600–800 m² hin.', 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'], + 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 @@ -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.', 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)'], + 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 @@ -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.', 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'], + 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 @@ -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.', 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'], + 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 @@ -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.', 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\'500–2\'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' }, + ], }, ] diff --git a/src/pages/supply/MarketIntelligence.tsx b/src/pages/supply/MarketIntelligence.tsx index 8c9ae3b..257ab56 100644 --- a/src/pages/supply/MarketIntelligence.tsx +++ b/src/pages/supply/MarketIntelligence.tsx @@ -1,9 +1,20 @@ -import { memo, useState, useEffect } from 'react' +import { memo, useState, useEffect, useCallback } 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 { + 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 type { MarketLead } from '../../hooks/useMarketLeads' +import type { Property } from '../../domain/property' +import type { ExtractedContact } from '../../domain/futureSignal' + +// ── Constants ──────────────────────────────────────────────────────────────── const SOURCE_LABELS: Record = { JOB_POSTING: 'Stelleninserate', @@ -15,12 +26,58 @@ const SOURCE_LABELS: Record = { LEASE_CONTRACT: 'Mietvertrag', } +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 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, }: { @@ -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 ( + + {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 */} - - {signal.companyName ?? signal.locationHint} - + + + {companyName} + + + {signal.title && ( {signal.title} )} - - - - {signal.areaSqmEstimate && ( - + {/* 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 + )} - - - {/* AI summary */} + {/* KI summary */} {(signal.aiSummary ?? signal.strategicInterpretation) && ( @@ -139,7 +381,7 @@ function LeadDetail({ lead }: { lead: MarketLead }) { )} - {/* Confirmed / unconfirmed facts */} + {/* Facts */} {((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && ( @@ -164,57 +406,54 @@ function LeadDetail({ lead }: { lead: MarketLead }) { - {/* Portfolio matches */} + {/* KI contact extraction */} - - Passende Objekte im Portfolio ({matchingProperties.length}) - - {matchingProperties.length === 0 ? ( - - Kein passendes Objekt gefunden — manuelle Prüfung empfohlen + + + + KI-Kontaktdaten - ) : ( - - {matchingProperties.slice(0, 3).map(p => ( - 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' }, - }} - > - - - {p.title} - - - {p.areaSqm.toLocaleString('de-CH')} m² - + + {(['HIGH','MEDIUM','LOW'] as const).map(c => ( + + + {CONF_LABEL[c]} ))} - {matchingProperties.length > 3 && ( - - +{matchingProperties.length - 3} weitere Objekte - - )} + + + {(signal.extractedContacts?.length ?? 0) === 0 ? ( + + Keine Kontaktdaten aus Crawler-Quellen extrahiert — manuelle Recherche empfohlen + + ) : ( + + {signal.extractedContacts!.map((c, i) => )} )} - {/* Action */} + + + {/* Portfolio matches */} - + + Passende Objekte im Portfolio ({matchingProperties.length}) + + {matchingProperties.length === 0 ? ( + + Kein passendes Portfolioobjekt gefunden — manuelle Prüfung empfohlen + + ) : ( + matchingProperties.map(p => ( + + )) + )} {signal.disclaimer && ( @@ -227,6 +466,8 @@ function LeadDetail({ lead }: { lead: MarketLead }) { ) } +// ── Page ───────────────────────────────────────────────────────────────────── + export default function MarketIntelligence() { const [selectedId, setSelectedId] = useState(null) const { data: leads, isLoading } = useMarketLeads() @@ -242,7 +483,7 @@ export default function MarketIntelligence() { Marktchancen - 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 @@ -250,7 +491,7 @@ export default function MarketIntelligence() { {/* Left pane */} - Markt-Signale + Erkannte Signale