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:
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
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'],
|
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 600–800 m² hin.',
|
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'],
|
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\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'],
|
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' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')} 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({
|
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 }}>
|
||||||
</Typography>
|
{companyName}
|
||||||
|
</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')} m²` : 'Fläche unbekannt'} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
|
||||||
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} 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>
|
</Typography>
|
||||||
) : (
|
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
{(['HIGH','MEDIUM','LOW'] as const).map(c => (
|
||||||
{matchingProperties.slice(0, 3).map(p => (
|
<Box key={c} sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
<Box
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: CONF_COLOR[c] }} />
|
||||||
key={p.id}
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.6rem' }}>{CONF_LABEL[c]}</Typography>
|
||||||
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>
|
</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' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user