Files
property-match/src/pages/supply/MarketIntelligence.tsx
T
Benjamin Sutter 6fb2fb027c refactor: rename tab to KI-Signale, diversify DEMAND signal narratives
- "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>
2026-05-24 23:53:30 +02:00

1205 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')}`
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')}` : '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}`
}
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax}`
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin}`
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}`
}
if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax}`
if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin}`
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>
)
}