refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements
- Taxonomy: merge VERIFIED_PORTFOLIO + EXTERNAL_MARKET display → 'Plattform' (dark blue) across all surfaces - HeatBadge: new flame indicator for hot properties (grid, list, pipeline views) - FutureAvailabilityContextPanel: richer detail page with AI summary, strategic assessment, sources - Refactor Pipeline.tsx (630→152 lines) → pipeline/PipelineCard, PipelineColumn, PipelineDetailPanel, pipelineConstants, pipelineUtils - Refactor IntelligenceMatchCard.tsx (483→179 lines) → FutureAvailabilityCard extracted - Refactor MatchDetail.tsx (559→464 lines) → useMatchDetailData hook, MatchDetailPropertyDetails - Refactor Compare.tsx (638→485 lines) → compareUtils, CompareCriteriaCard extracted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
||||
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart2,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
Newspaper,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
User,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
@@ -25,14 +30,14 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
LEASE_EXPIRY: 'Vertragsende (Pre-Market)',
|
||||
}
|
||||
|
||||
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
||||
EXPANSION: 'Ein Unternehmen sucht neue Flächen — Ihr Angebot könnte gefragt sein.',
|
||||
POSSIBLE_MOVE_OUT: 'Dieses Unternehmen könnte seinen Standort aufgeben — die Fläche wird für Sie verfügbar.',
|
||||
CONSTRUCTION_PROJECT:'Ein Neubau entsteht — neue Flächen könnten zur Vermietung angeboten werden.',
|
||||
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
|
||||
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
|
||||
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
|
||||
LEASE_EXPIRY: 'Die Verwaltung hat diese Fläche für kontrolliertes Pre-Market Matching freigegeben — Vertragsende aus internem ERP bestätigt.',
|
||||
const SIGNAL_ACTION: Record<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
||||
EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' },
|
||||
POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' },
|
||||
CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' },
|
||||
RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' },
|
||||
PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' },
|
||||
SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' },
|
||||
LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' },
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -57,6 +62,32 @@ const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warnin
|
||||
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
||||
}
|
||||
|
||||
const RISK_META: Record<string, { label: string; color: string }> = {
|
||||
LOW: { label: 'Niedrig', color: '#1a7a4a' },
|
||||
MEDIUM: { label: 'Mittel', color: '#d97706' },
|
||||
HIGH: { label: 'Hoch', color: '#c0392b' },
|
||||
CRITICAL: { label: 'Kritisch', color: '#7f1d1d' },
|
||||
}
|
||||
|
||||
function ageLabel(isoDate: string): string {
|
||||
const diffMs = Date.now() - new Date(isoDate).getTime()
|
||||
const days = Math.floor(diffMs / 86400000)
|
||||
if (days < 1) return 'Heute erkannt'
|
||||
if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt`
|
||||
if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt`
|
||||
if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt`
|
||||
return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt`
|
||||
}
|
||||
|
||||
function domainLabel(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return url.length > 60 ? url.slice(0, 57) + '…' : url
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
signal: FutureSignal | null
|
||||
@@ -66,15 +97,24 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
if (!signal) return null
|
||||
|
||||
const sourceMeta = SOURCE_META[signal.source.type] ?? null
|
||||
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null
|
||||
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null
|
||||
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
|
||||
const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
|
||||
const probPct = Math.round(signal.probability * 100)
|
||||
const riskMeta = signal.riskLevel ? (RISK_META[signal.riskLevel] ?? null) : null
|
||||
const probPct = Math.round(signal.probability * 100)
|
||||
const isVerifiedContract = signal.isVerified && signal.source.type === 'LEASE_CONTRACT'
|
||||
const action = signal.signalType ? (SIGNAL_ACTION[signal.signalType] ?? null) : null
|
||||
const allSourceUrls = [
|
||||
...(signal.source.url ? [signal.source.url] : []),
|
||||
...(signal.evidence?.sourceUrls ?? []),
|
||||
]
|
||||
|
||||
const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b'
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
|
||||
<Zap size={16} color="#7c3aed" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
||||
{signal.isVerified && (
|
||||
@@ -83,34 +123,185 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}>
|
||||
<Clock size={12} />
|
||||
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 1. Quelle & Verlässlichkeit — FIRST */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Quelle & Verlässlichkeit</Typography>
|
||||
{signal.title && (
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: isVerifiedContract ? 1 : 0 }}>
|
||||
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
|
||||
{signal.aiSummary && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||
<Sparkles size={14} color="#7c3aed" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
|
||||
{signal.aiSummary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
|
||||
{signal.strategicInterpretation && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||
<TrendingUp size={14} color="#0369a1" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
|
||||
{signal.strategicInterpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
|
||||
{action && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}>
|
||||
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
|
||||
|
||||
{/* Probability bar */}
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
|
||||
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: probBarColor }}>{probPct}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={probPct}
|
||||
sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{signal.signalType && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Calendar size={13} color="#64748b" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{signal.areaSqmEstimate && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{riskMeta && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Risikoniveau</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: riskMeta.color }}>{riskMeta.label}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
|
||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{signal.marketIndicators.map((indicator, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
|
||||
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
||||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Transparenz — Was ist gesichert?</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
||||
<Globe size={14} color="#0369a1" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen & Belege</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Primary source metadata — always shown */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
|
||||
{sourceMeta && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
||||
{sourceMeta.icon}
|
||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{credMeta && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: credMeta.color }} />
|
||||
<Typography variant="body2" sx={{ color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{signal.source.publishedAt && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||
<Calendar size={11} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Verified contract special box */}
|
||||
{isVerifiedContract && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mt: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<ShieldCheck size={13} color="#1a7a4a" />
|
||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
|
||||
@@ -122,176 +313,72 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{signal.source.url && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button
|
||||
component="a"
|
||||
href={signal.source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ExternalLink size={13} />}
|
||||
sx={{ textTransform: 'none', color: '#7c3aed', borderColor: '#c4b5fd', '&:hover': { borderColor: '#7c3aed', bgcolor: '#faf5ff' } }}
|
||||
>
|
||||
Originalquelle öffnen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Weitere Belege ({signal.evidence.sourceUrls.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{signal.evidence.sourceUrls.map((url, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#7c3aed', fontSize: '0.75rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
<Typography variant="caption" sx={{ color: 'inherit' }}>{url}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* 2. Signal-Kenndaten */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
|
||||
{signal.signalType && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
{signal.areaSqmEstimate && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* 3. KI-Analyse */}
|
||||
{signal.aiSummary && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||
<Zap size={14} color="#7c3aed" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95' }}>KI-Analyse</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.65 }}>
|
||||
{signal.aiSummary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* 4. Erkannte Marktindikatoren */}
|
||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
|
||||
Erkannte Marktindikatoren
|
||||
</Typography>
|
||||
{/* All source URLs */}
|
||||
{allSourceUrls.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||
{signal.marketIndicators.map((indicator, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.45 }}>{indicator}</Typography>
|
||||
</Box>
|
||||
{allSourceUrls.map((url, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ExternalLink size={12} />}
|
||||
sx={{
|
||||
textTransform: 'none', justifyContent: 'flex-start',
|
||||
color: '#0369a1', borderColor: '#bae6fd',
|
||||
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
|
||||
fontSize: '0.78rem', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{domainLabel(url)}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 5. Strategische Interpretation */}
|
||||
{signal.strategicInterpretation && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75, fontSize: '0.8125rem' }}>
|
||||
Strategische Interpretation
|
||||
</Typography>
|
||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.65, fontStyle: 'italic' }}>
|
||||
{signal.strategicInterpretation}
|
||||
) : (
|
||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 6. Transparenz (Bestätigt / Nicht bestätigt) */}
|
||||
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
||||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
|
||||
Transparenz
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<AlertTriangle size={13} color="#d97706" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{/* Evidence extraction date */}
|
||||
{signal.evidence?.extractedAt && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
|
||||
<Clock size={11} color="#94a3b8" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 7. Relevance bridge */}
|
||||
{relevanceBridge && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1.25, mb: 2 }}>
|
||||
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#4c1d95', mb: 0.25 }}>Warum erscheint dieses Signal in Ihrer Suche?</Typography>
|
||||
<Typography sx={{ fontSize: '0.8rem', color: '#4c1d95', lineHeight: 1.5 }}>{relevanceBridge}</Typography>
|
||||
{/* Evidence summary */}
|
||||
{signal.evidence?.summary && (
|
||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 5. Evidence */}
|
||||
{signal.evidence?.summary && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
|
||||
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 1, p: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{/* ── Footer ──────────────────────────────────────────────────────────── */}
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* 6. Vertraulichkeit */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary">Vertraulichkeit:</Typography>
|
||||
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
||||
{signal.verifiedBy && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 7. Disclaimer — grey footnote */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.5, borderTop: '1px solid #f1f5f9', pt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
|
||||
// ── Property detail helpers ────────────────────────────────────────────────────
|
||||
|
||||
export const FLOOR_LABEL = (level: number) =>
|
||||
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
|
||||
|
||||
export const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
|
||||
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
|
||||
}
|
||||
export const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
|
||||
export const SOURCE_LABELS: Record<string, string> = {
|
||||
ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24',
|
||||
HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice',
|
||||
NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst',
|
||||
}
|
||||
export const PASSERBY_LABELS: Record<string, string> = {
|
||||
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
|
||||
}
|
||||
|
||||
export function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
|
||||
if (unit.schattenmarktRelease?.enabled) {
|
||||
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
if (unit.available) {
|
||||
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
|
||||
export function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
|
||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
|
||||
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
|
||||
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
|
||||
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
|
||||
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
|
||||
</Typography>
|
||||
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'}
|
||||
</Typography>
|
||||
<UnitStatusChip unit={unit} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { MissingInformationPanel } from './MissingInformationPanel'
|
||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||
|
||||
Reference in New Issue
Block a user