6fb2fb027c
- "Digitale Signale" → "KI-Signale" tab label - signal-001: Alpbach Advisors — new Zürich branch announced via press release - signal-007: EBH Treuhand — Handelsregister merger of two Winterthur firms - signal-010: Centra Schweiz — suburban→city relocation for talent attraction - signal-013: Rheintaler Logistik Solutions — spin-off needs its own premises - signal-015: BernTech — CEO publicly confirmed new office in VC press release - signal-004 improved: lab expansion driven by published strategic plan, not headcount - Each signal uses a different trigger: press/Handelsregister/annual report/VC round Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1205 lines
49 KiB
TypeScript
1205 lines
49 KiB
TypeScript
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<string, string> = {
|
||
JOB_POSTING: 'Stelleninserate',
|
||
PRESS: 'Pressebericht',
|
||
COMPANY_REPORT: 'Geschäftsbericht',
|
||
MARKET_DATA: 'Marktdaten',
|
||
MANUAL: 'Manuell',
|
||
CONSTRUCTION_PERMIT: 'Baugenehmigung',
|
||
LEASE_CONTRACT: 'Mietvertrag',
|
||
}
|
||
|
||
const QUELLE_LABELS: Record<string, string> = {
|
||
NETZWERKEVENT: 'Netzwerkevent',
|
||
TELEFONAT: 'Telefonat',
|
||
BESICHTIGUNG: 'Besichtigung',
|
||
MESSE: 'Messe',
|
||
EMAIL: 'E-Mail',
|
||
SONSTIGES: 'Sonstiges',
|
||
}
|
||
|
||
const ASSET_TYPE_LABELS: Record<string, string> = {
|
||
OFFICE: 'Büro',
|
||
RETAIL: 'Einzelhandel',
|
||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||
LOGISTICS: 'Logistik',
|
||
PRODUCTION: 'Produktion',
|
||
MIXED: 'Gemischt',
|
||
}
|
||
|
||
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 {
|
||
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 (
|
||
<Box
|
||
onClick={onClick}
|
||
sx={{
|
||
px: 2, py: 1.5,
|
||
borderBottom: '1px solid #f1f5f9',
|
||
borderLeft: `3px solid ${selected ? '#1e3a5f' : 'transparent'}`,
|
||
bgcolor: selected ? '#f0f9ff' : 'white',
|
||
cursor: 'pointer',
|
||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
||
transition: 'background 0.1s',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.25 }}>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
|
||
{signal.companyName ?? signal.locationHint}
|
||
</Typography>
|
||
<Chip
|
||
label={`${prob}%`}
|
||
size="small"
|
||
sx={{ height: 18, fontSize: '0.65rem', fontWeight: 700, bgcolor: `${color}18`, color, flexShrink: 0 }}
|
||
/>
|
||
</Box>
|
||
{signal.title && (
|
||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.5, lineHeight: 1.3 }}>
|
||
{signal.title}
|
||
</Typography>
|
||
)}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem' }}>
|
||
{signal.locationHint} · {signal.timeHorizonMonths} Mo.
|
||
</Typography>
|
||
{matchingProperties.length > 0 && (
|
||
<Chip
|
||
label={`${matchingProperties.length} Obj.`}
|
||
size="small"
|
||
sx={{ ml: 'auto', height: 16, fontSize: '0.6rem', fontWeight: 600, bgcolor: '#dcfce7', color: '#16a34a' }}
|
||
/>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})
|
||
|
||
// ── 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 [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 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 (
|
||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||
{/* Header */}
|
||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, mb: 0.5 }}>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||
{companyName}
|
||
</Typography>
|
||
<Chip label={`${prob}% Wahrscheinlichkeit`} size="small" sx={{ bgcolor: `${color}18`, color, fontWeight: 700, flexShrink: 0 }} />
|
||
</Box>
|
||
{signal.title && (
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||
{signal.title}
|
||
</Typography>
|
||
)}
|
||
{/* Stats row */}
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||
<Chip icon={<MapPin size={11} />} label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
|
||
<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.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>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||
{/* KI summary */}
|
||
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
||
<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 }}>
|
||
<Sparkles size={13} color="#1d4ed8" />
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
|
||
KI-Analyse
|
||
</Typography>
|
||
</Box>
|
||
<Typography variant="body2" sx={{ color: '#1e3a8a', lineHeight: 1.6 }}>
|
||
{signal.aiSummary ?? signal.strategicInterpretation}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Market indicators */}
|
||
{(signal.marketIndicators?.length ?? 0) > 0 && (
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||
Erkannte Nachfragesignale
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||
{signal.marketIndicators!.map((ind, i) => (
|
||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||
<TrendingUp size={12} color="#1e3a5f" style={{ marginTop: 3, flexShrink: 0 }} />
|
||
<Typography variant="body2" sx={{ lineHeight: 1.5, color: '#1e293b' }}>{ind}</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Facts */}
|
||
{((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && (
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||
Faktencheck
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||
{(signal.confirmedFacts ?? []).map((f, i) => (
|
||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||
<CheckCircle2 size={13} color="#16a34a" style={{ flexShrink: 0 }} />
|
||
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 500 }}>{f}</Typography>
|
||
</Box>
|
||
))}
|
||
{(signal.unconfirmedFacts ?? []).map((f, i) => (
|
||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||
<AlertCircle size={13} color="#d97706" style={{ flexShrink: 0 }} />
|
||
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 500 }}>{f}</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
{/* KI contact extraction */}
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||
<Sparkles size={13} color="#7c3aed" />
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
|
||
KI-Kontaktdaten
|
||
</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>
|
||
</Box>
|
||
{(signal.extractedContacts?.length ?? 0) === 0 ? (
|
||
<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>
|
||
|
||
<Divider />
|
||
|
||
{/* Portfolio matches */}
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||
Passende Objekte im Portfolio ({matchingProperties.length})
|
||
</Typography>
|
||
{matchingProperties.length === 0 ? (
|
||
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
|
||
Kein passendes Portfolioobjekt gefunden — manuelle Prüfung empfohlen
|
||
</Alert>
|
||
) : (
|
||
matchingProperties.map(p => (
|
||
<PropertyMatchCard
|
||
key={p.id}
|
||
p={p}
|
||
signal={signal}
|
||
companyName={companyName}
|
||
/>
|
||
))
|
||
)}
|
||
</Box>
|
||
|
||
{signal.disclaimer && (
|
||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||
{signal.disclaimer}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<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: 'default',
|
||
}}
|
||
>
|
||
<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>
|
||
</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>
|
||
)
|
||
})
|
||
|
||
// ── 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 <Chip label="Intern" size="small" sx={{ bgcolor: '#eef2ff', color: '#4338ca', height: 18, fontSize: '0.6rem' }} />
|
||
}
|
||
if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) {
|
||
return <Chip label="Geteilt" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', height: 18, fontSize: '0.6rem' }} />
|
||
}
|
||
return <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', height: 18, fontSize: '0.6rem' }} />
|
||
}, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName])
|
||
|
||
return (
|
||
<Box
|
||
onClick={onClick}
|
||
sx={{
|
||
px: 2, py: 1.5,
|
||
borderBottom: '1px solid #f1f5f9',
|
||
borderLeft: `3px solid ${selected ? '#1e3a5f' : 'transparent'}`,
|
||
bgcolor: selected ? '#f0f9ff' : 'white',
|
||
cursor: 'pointer',
|
||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
||
transition: 'background 0.1s',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.25 }}>
|
||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
||
{hinweis.direction === 'SUCHE'
|
||
? <Search size={14} color="#1e3a5f" />
|
||
: <Building2 size={14} color="#7c3aed" />
|
||
}
|
||
</Box>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{displayName}
|
||
</Typography>
|
||
<Box sx={{ flexShrink: 0 }}>{visibilityBadge}</Box>
|
||
</Box>
|
||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block', mb: 0.5, pl: 2.75 }}>
|
||
{hinweis.locationHint}{areaStr}
|
||
</Typography>
|
||
<Box sx={{ pl: 2.75 }}>
|
||
{hinweis.direction === 'SUCHE'
|
||
? <Chip label="Suche" size="small" sx={{ bgcolor: '#dbeafe', color: '#1d4ed8', height: 16, fontSize: '0.6rem' }} />
|
||
: <Chip label="Wird frei" size="small" sx={{ bgcolor: '#dcfce7', color: '#16a34a', height: 16, fontSize: '0.6rem' }} />
|
||
}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})
|
||
|
||
// ── 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 (
|
||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||
{/* Header */}
|
||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 1 }}>
|
||
{displayName}
|
||
</Typography>
|
||
|
||
{/* Badge row */}
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||
{hinweis.direction === 'SUCHE'
|
||
? <Chip label="Suche" size="small" sx={{ bgcolor: '#dbeafe', color: '#1d4ed8', fontWeight: 600, fontSize: '0.75rem' }} />
|
||
: <Chip label="Wird verfügbar" size="small" sx={{ bgcolor: '#dcfce7', color: '#16a34a', fontWeight: 600, fontSize: '0.75rem' }} />
|
||
}
|
||
{hinweis.visibility === 'INTERN'
|
||
? <Chip label="Intern" size="small" sx={{ bgcolor: '#eef2ff', color: '#4338ca', fontWeight: 600, fontSize: '0.75rem' }} />
|
||
: hinweis.verwaltungId === OWN_VERWALTUNG_ID
|
||
? <Chip label="Geteilt" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600, fontSize: '0.75rem' }} />
|
||
: <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 600, fontSize: '0.75rem' }} />
|
||
}
|
||
</Box>
|
||
|
||
{/* Stat chips */}
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||
<Chip icon={<MapPin size={11} />} label={hinweis.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
|
||
{areaStr && <Chip icon={<Ruler size={11} />} label={areaStr} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />}
|
||
<Chip label={ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
|
||
</Box>
|
||
|
||
{/* Quelle row */}
|
||
<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' }}>
|
||
Quelle: <strong>{QUELLE_LABELS[hinweis.quelle] ?? hinweis.quelle}</strong>
|
||
{' · '}{hinweis.createdBy}
|
||
{' · '}{formatDate(hinweis.createdAt)}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Body */}
|
||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
{hinweis.note && (
|
||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
|
||
Notiz
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.6 }}>
|
||
{hinweis.note}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
{hinweis.direction === 'SUCHE' ? (
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||
Passende Objekte im Portfolio ({matchingProperties.length})
|
||
</Typography>
|
||
{matchingProperties.length === 0 ? (
|
||
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
|
||
Kein passendes Portfolioobjekt gefunden
|
||
</Alert>
|
||
) : (
|
||
matchingProperties.map(p => (
|
||
<HinweisPropertyCard key={p.id} p={p} hinweis={hinweis} />
|
||
))
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||
Verfügbarkeitssignal — wird auf der Plattform für andere Verwaltungen sichtbar gemacht
|
||
</Alert>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
// ── Netzwerk: HinweisErfassenDialog ─────────────────────────────────────────
|
||
|
||
function HinweisErfassenDialog({
|
||
open,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
open: boolean
|
||
onClose: () => void
|
||
onCreated: (id: string) => void
|
||
}) {
|
||
const mutation = useCreateMarktHinweis()
|
||
|
||
const [direction, setDirection] = useState<HinweisDirection>('SUCHE')
|
||
const [assetType, setAssetType] = useState<string>('')
|
||
const [locationHint, setLocationHint] = useState<string>('')
|
||
const [areaSqmMin, setAreaSqmMin] = useState<string>('')
|
||
const [areaSqmMax, setAreaSqmMax] = useState<string>('')
|
||
const [companyName, setCompanyName] = useState<string>('')
|
||
const [isAnonymized, setIsAnonymized] = useState<boolean>(false)
|
||
const [quelle, setQuelle] = useState<HinweisQuelle>('NETZWERKEVENT')
|
||
const [note, setNote] = useState<string>('')
|
||
const [visibility, setVisibility] = useState<HinweisVisibility>('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 (
|
||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||
<DialogTitle>Hinweis erfassen</DialogTitle>
|
||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||
{/* Richtung */}
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||
Richtung
|
||
</Typography>
|
||
<ToggleButtonGroup
|
||
value={direction}
|
||
exclusive
|
||
onChange={(_, v) => { if (v) setDirection(v as HinweisDirection) }}
|
||
size="small"
|
||
sx={{ '& .MuiToggleButton-root': { textTransform: 'none', fontSize: '0.8rem' } }}
|
||
>
|
||
<ToggleButton value="SUCHE">
|
||
<Search size={14} style={{ marginRight: 6 }} /> Jemand sucht
|
||
</ToggleButton>
|
||
<ToggleButton value="VERFUEGBARKEIT">
|
||
<Building2 size={14} style={{ marginRight: 6 }} /> Wird verfügbar
|
||
</ToggleButton>
|
||
</ToggleButtonGroup>
|
||
</Box>
|
||
|
||
{/* Asset-Typ */}
|
||
<FormControl fullWidth required>
|
||
<InputLabel>Asset-Typ *</InputLabel>
|
||
<MuiSelect
|
||
value={assetType}
|
||
label="Asset-Typ *"
|
||
onChange={e => setAssetType(e.target.value)}
|
||
>
|
||
<MenuItem value="OFFICE">Büro</MenuItem>
|
||
<MenuItem value="RETAIL">Einzelhandel</MenuItem>
|
||
<MenuItem value="LIGHT_INDUSTRIAL">Gewerbe</MenuItem>
|
||
<MenuItem value="LOGISTICS">Logistik</MenuItem>
|
||
<MenuItem value="PRODUCTION">Produktion</MenuItem>
|
||
</MuiSelect>
|
||
</FormControl>
|
||
|
||
{/* Stadt / Region */}
|
||
<TextField
|
||
fullWidth
|
||
required
|
||
label="Stadt / Region"
|
||
value={locationHint}
|
||
onChange={e => setLocationHint(e.target.value)}
|
||
/>
|
||
|
||
{/* Fläche */}
|
||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||
<TextField
|
||
label="Von (m²)"
|
||
type="number"
|
||
value={areaSqmMin}
|
||
onChange={e => setAreaSqmMin(e.target.value)}
|
||
sx={{ flex: 1 }}
|
||
/>
|
||
<TextField
|
||
label="Bis (m²)"
|
||
type="number"
|
||
value={areaSqmMax}
|
||
onChange={e => setAreaSqmMax(e.target.value)}
|
||
sx={{ flex: 1 }}
|
||
/>
|
||
</Box>
|
||
|
||
{/* Firma / Name */}
|
||
<TextField
|
||
fullWidth
|
||
label="Firma / Name (optional)"
|
||
value={companyName}
|
||
onChange={e => setCompanyName(e.target.value)}
|
||
/>
|
||
|
||
{/* Quelle */}
|
||
<FormControl fullWidth>
|
||
<InputLabel>Quelle</InputLabel>
|
||
<MuiSelect
|
||
value={quelle}
|
||
label="Quelle"
|
||
onChange={e => setQuelle(e.target.value as HinweisQuelle)}
|
||
>
|
||
{Object.entries(QUELLE_LABELS).map(([k, v]) => (
|
||
<MenuItem key={k} value={k}>{v}</MenuItem>
|
||
))}
|
||
</MuiSelect>
|
||
</FormControl>
|
||
|
||
{/* Notiz */}
|
||
<TextField
|
||
fullWidth
|
||
multiline
|
||
minRows={3}
|
||
label="Notiz (optional)"
|
||
value={note}
|
||
onChange={e => setNote(e.target.value)}
|
||
/>
|
||
|
||
{/* Sichtbarkeit */}
|
||
<Box>
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||
Sichtbarkeit
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||
<Box
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||
<Lock size={14} color={visibility === 'INTERN' ? '#4338ca' : '#64748b'} />
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: visibility === 'INTERN' ? '#4338ca' : '#0f172a' }}>Intern</Typography>
|
||
</Box>
|
||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem' }}>Nur für Ihr Team sichtbar</Typography>
|
||
</Box>
|
||
<Box
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||
<Globe size={14} color={visibility === 'PLATTFORM' ? '#7c3aed' : '#64748b'} />
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: visibility === 'PLATTFORM' ? '#7c3aed' : '#0f172a' }}>Plattform</Typography>
|
||
</Box>
|
||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem' }}>Für alle Verwaltungen auf Property Match sichtbar</Typography>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Anonymisieren (only for PLATTFORM) */}
|
||
{visibility === 'PLATTFORM' && (
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={isAnonymized}
|
||
onChange={e => setIsAnonymized(e.target.checked)}
|
||
size="small"
|
||
/>
|
||
}
|
||
label={<Typography variant="body2">Firmenname anonymisieren</Typography>}
|
||
/>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={handleClose} sx={{ textTransform: 'none' }}>Abbrechen</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={handleSave}
|
||
disabled={mutation.isPending || !isValid}
|
||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||
>
|
||
Speichern
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||
|
||
export default function MarketIntelligence() {
|
||
const [activeTab, setActiveTab] = useState(0)
|
||
const [selectedSignalId, setSelectedSignalId] = useState<string | null>(null)
|
||
const [selectedHinweisId, setSelectedHinweisId] = useState<string | null>(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 (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||
{/* Page header */}
|
||
<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="caption" color="text.secondary">
|
||
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Tabs */}
|
||
<Tabs
|
||
value={activeTab}
|
||
onChange={(_, v: number) => setActiveTab(v)}
|
||
sx={{
|
||
borderBottom: '1px solid #e2e8f0',
|
||
flexShrink: 0,
|
||
bgcolor: 'white',
|
||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 },
|
||
}}
|
||
>
|
||
<Tab icon={<Sparkles size={14} />} iconPosition="start" label="KI-Signale" />
|
||
<Tab icon={<Users size={14} />} iconPosition="start" label="Netzwerk" />
|
||
</Tabs>
|
||
|
||
{/* Tab 0: KI-Signale */}
|
||
{activeTab === 0 && (
|
||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||
{/* Left pane */}
|
||
<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 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography>
|
||
<Chip label={leadsLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} />
|
||
</Box>
|
||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||
{leadsLoading
|
||
? [1, 2, 3, 4].map(i => (
|
||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||
<Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} />
|
||
<Skeleton variant="text" width="80%" height={14} />
|
||
<Skeleton variant="text" width="40%" height={12} />
|
||
</Box>
|
||
))
|
||
: leads.map(lead => (
|
||
<LeadListItem
|
||
key={lead.signal.id}
|
||
lead={lead}
|
||
selected={lead.signal.id === selectedSignalId}
|
||
onClick={() => setSelectedSignalId(lead.signal.id)}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Right pane */}
|
||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||
{selectedLead ? (
|
||
<LeadDetail lead={selectedLead} />
|
||
) : (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<Typography variant="body2" color="text.disabled">Signal aus der Liste auswählen</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Tab 1: Netzwerk */}
|
||
{activeTab === 1 && (
|
||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||
{/* Left pane */}
|
||
<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', flexShrink: 0 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Netzwerk-Hinweise</Typography>
|
||
<Chip
|
||
label={hinweiseLoading ? '…' : filteredHinweise.length}
|
||
size="small"
|
||
sx={{ bgcolor: '#7c3aed', color: '#fff', fontSize: '0.7rem', height: 20 }}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
startIcon={<Plus size={13} />}
|
||
onClick={() => setErfassenOpen(true)}
|
||
sx={{
|
||
textTransform: 'none', fontSize: '0.72rem',
|
||
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||
minWidth: 'auto',
|
||
}}
|
||
>
|
||
Erfassen
|
||
</Button>
|
||
</Box>
|
||
{/* Filter chips */}
|
||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||
{(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => (
|
||
<Chip
|
||
key={f}
|
||
label={f === 'ALL' ? 'Alle' : f === 'INTERN' ? 'Intern' : 'Plattform'}
|
||
size="small"
|
||
onClick={() => setHinweisFilter(f)}
|
||
sx={{
|
||
height: 22, fontSize: '0.68rem', cursor: 'pointer',
|
||
bgcolor: hinweisFilter === f ? '#1e3a5f' : '#f1f5f9',
|
||
color: hinweisFilter === f ? 'white' : '#475569',
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* List */}
|
||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||
{hinweiseLoading
|
||
? [1, 2, 3].map(i => (
|
||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||
<Skeleton variant="text" width="60%" />
|
||
<Skeleton variant="text" width="80%" height={12} />
|
||
</Box>
|
||
))
|
||
: filteredHinweise.map(h => (
|
||
<HinweisListItem
|
||
key={h.id}
|
||
hinweis={h}
|
||
selected={h.id === selectedHinweisId}
|
||
onClick={() => setSelectedHinweisId(h.id)}
|
||
/>
|
||
))
|
||
}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Right pane */}
|
||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||
{selectedHinweis ? (
|
||
<HinweisDetail hinweis={selectedHinweis} />
|
||
) : (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<Typography variant="body2" color="text.disabled">Hinweis aus der Liste auswählen</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Dialog */}
|
||
<HinweisErfassenDialog
|
||
open={erfassenOpen}
|
||
onClose={() => setErfassenOpen(false)}
|
||
onCreated={(id) => {
|
||
setSelectedHinweisId(id)
|
||
setErfassenOpen(false)
|
||
setActiveTab(1)
|
||
}}
|
||
/>
|
||
</Box>
|
||
)
|
||
}
|