@@ -1,21 +1,29 @@
import { memo , useState , useEffect , useCallback } from 'react'
import { memo , useState , useEffect , useCallback , useMemo } from 'react'
import { useNavigate } from 'react-router'
import {
Alert , Box , Button , Chip , Divider , IconButton ,
Skeleton , TextField , Tooltip , Typography ,
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 , Mail , MapPin , Phone ,
Ruler , Sparkles , TrendingUp , User ,
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' ,
@@ -26,6 +34,24 @@ const SOURCE_LABELS: Record<string, string> = {
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 } / > ,
@@ -56,6 +82,10 @@ 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²) `
@@ -466,65 +496,709 @@ function LeadDetail({ lead }: { lead: MarketLead }) {
)
}
// ── Page ───────────────────────── ────────────────────────────────────────────
// ── Netzwerk: HinweisPropertyCard ────────────────────────────────────────────
export default function MarketIntelligence() {
const [ selectedId , setSelectedId ] = useState < string | null > ( null )
const { data : leads , isLoading } = useMarketLeads ( )
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
useEffect ( ( ) = > {
if ( ! selectedId && leads . length > 0 ) setSelectedId ( leads [ 0 ] . signal . id )
} , [ leads , selectedId ] )
const selectedLead = leads . find ( l = > l . signal . id === selectedId ) ? ? null
const handleCopy = useCallback ( ( ) = > {
navigator . clipboard . writeText ( draft )
setCopied ( true )
setTimeout ( ( ) = > setCopied ( false ) , 2000 )
} , [ draft ] )
return (
< Box sx = { { display : 'flex' , flexDirection : 'column' , height : '100%' , overflow : 'hidden' } } >
< 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 - erkannte Unternehmen mit Flächenbedarf · automatisch aus Web - Quellen · mit Portfolio - Match und Anschreiben
< / Typography >
< 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 >
< 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 = { isLoading ? '…' : leads . length } size = "small" sx = { { bgcolor : '#1e3a5f' , color : '#fff' , fontSize : '0.7rem' , height : 20 } } / >
< / Box >
< Box sx = { { flex : 1 , overflowY : 'auto' } } >
{ isLoading
? [ 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 === selectedId }
onClick = { ( ) = > setSelectedId ( lead . signal . id ) }
/ >
) ) }
{ /* 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 >
{ /* 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 >
) }
{ /* 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 = "Digitale Signale" / >
< Tab icon = { < Users size = { 14 } / > } iconPosition = "start" label = "Netzwerk" / >
< / Tabs >
{ /* Tab 0: Digitale 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 >
)
}