feat: redesign Schattenmarkt card & detail view for explainability
- Remove abstract Konfidenz/DQ badges from match detail header - Replace ScoreBreakdownPanel with 3-step Bewertungsherleitung for FUTURE_AVAILABILITY: Kriterien-Match per factor with bars, Signal- Abschlag (verified ERP = kein Abschlag / probabilistic = -N Punkte), Gesamt-Score - Restructure FutureAvailabilityContextPanel: source section first, verified ERP box for LEASE_CONTRACT, remove Konfidenz row, disclaimer as grey footnote instead of yellow alert - Add LEASE_EXPIRY to signal type/relevance bridge labels - Show up to 3 match reasons with explanation on cards (was 1) - matchCardAdapter: return top-3 positive factors as reasons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -258,7 +258,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const topReason = vm.reasons[0]
|
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -360,14 +359,23 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{topReason && (
|
{vm.reasons.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ my: 1.25 }} />
|
<Divider sx={{ my: 1.25 }} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600, lineHeight: 1.4 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
{topReason.label}
|
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
</Typography>
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alert, Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
@@ -20,6 +20,7 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|||||||
RESTRUCTURING: 'Restrukturierung',
|
RESTRUCTURING: 'Restrukturierung',
|
||||||
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
||||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||||
|
LEASE_EXPIRY: 'Vertragsende (Schattenmarkt)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
||||||
@@ -29,6 +30,7 @@ const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
|||||||
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
|
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
|
||||||
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
|
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
|
||||||
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
|
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
|
||||||
|
LEASE_EXPIRY: 'Die Verwaltung hat dieses Objekt für den Markt freigegeben — Vertragsende bekannt und bestätigt.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
@@ -38,6 +40,7 @@ const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
|||||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
||||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
||||||
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
||||||
|
LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: <ShieldCheck size={13} /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||||
@@ -65,11 +68,11 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
|
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
|
||||||
const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
|
const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
|
||||||
const probPct = Math.round(signal.probability * 100)
|
const probPct = Math.round(signal.probability * 100)
|
||||||
const confPct = Math.round(signal.confidenceScore * 100)
|
const isVerifiedContract = signal.isVerified && signal.source.type === 'LEASE_CONTRACT'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<Zap size={16} color="#7c3aed" />
|
<Zap size={16} color="#7c3aed" />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Schattenmarkt-Signal</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Schattenmarkt-Signal</Typography>
|
||||||
{signal.isVerified && (
|
{signal.isVerified && (
|
||||||
@@ -80,23 +83,87 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Mandatory disclaimer */}
|
{/* 1. Quelle & Verlässlichkeit — FIRST */}
|
||||||
<Alert severity="warning" sx={{ mb: 2, fontSize: '0.8rem' }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
{signal.disclaimer}
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Quelle & Verlässlichkeit</Typography>
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Relevance bridge */}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: isVerifiedContract ? 1 : 0 }}>
|
||||||
{relevanceBridge && (
|
{sourceMeta && (
|
||||||
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
||||||
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
|
{sourceMeta.icon}
|
||||||
<Box>
|
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#4c1d95', mb: 0.25 }}>Warum erscheint dieses Signal in Ihrer Suche?</Typography>
|
</Box>
|
||||||
<Typography sx={{ fontSize: '0.8rem', color: '#4c1d95', lineHeight: 1.5 }}>{relevanceBridge}</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>
|
||||||
|
)}
|
||||||
|
{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>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Key metrics row */}
|
{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', 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>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<ShieldCheck size={13} color="#1a7a4a" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Verwaltung hat Marktfreigabe erteilt</Typography>
|
||||||
|
</Box>
|
||||||
|
</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 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
|
||||||
{signal.signalType && (
|
{signal.signalType && (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -108,10 +175,6 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
|
<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>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Konfidenz</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{confPct}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||||
@@ -122,15 +185,11 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Vertraulichkeit</Typography>
|
|
||||||
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* AI Summary */}
|
{/* 3. KI-Analyse */}
|
||||||
{signal.aiSummary && (
|
{signal.aiSummary && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
@@ -145,7 +204,18 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Evidence */}
|
{/* 4. 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>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5. Evidence */}
|
||||||
{signal.evidence?.summary && (
|
{signal.evidence?.summary && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
|
||||||
@@ -155,66 +225,16 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
{/* 6. Vertraulichkeit */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
{/* Source section */}
|
<Typography variant="caption" color="text.secondary">Vertraulichkeit:</Typography>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>Quelle</Typography>
|
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
{sourceMeta && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
|
||||||
{sourceMeta.icon}
|
|
||||||
<Typography sx={{ fontSize: '0.8rem', 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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{signal.source.url && (
|
|
||||||
<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>
|
</Box>
|
||||||
|
|
||||||
{signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && (
|
{/* 7. Disclaimer — grey footnote */}
|
||||||
<Box sx={{ mt: 1.5 }}>
|
<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={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
{signal.disclaimer}
|
||||||
Weitere Belege ({signal.evidence.sourceUrls.length})
|
</Typography>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
||||||
import { ExternalLink } from 'lucide-react'
|
import { CheckCircle2, ExternalLink, ShieldCheck, X } from 'lucide-react'
|
||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
|
||||||
|
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
||||||
|
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREDIBILITY_LABELS: Record<string, string> = {
|
||||||
|
HIGH: 'Hohe Quellenqualität',
|
||||||
|
MEDIUM: 'Mittlere Quellenqualität',
|
||||||
|
LOW: 'Niedrige Quellenqualität',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||||
|
|
||||||
interface BreakdownRowProps {
|
interface BreakdownRowProps {
|
||||||
label: string
|
label: string
|
||||||
@@ -40,18 +55,174 @@ function BreakdownRow({ label, value, max, description, color = 'primary', modif
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signal score derivation (FUTURE_AVAILABILITY) ─────────────────────────────
|
||||||
|
|
||||||
|
interface SignalScoreDerivationProps {
|
||||||
|
match: Match
|
||||||
|
signal: FutureSignal | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
||||||
|
const sb = match.scoreBreakdown
|
||||||
|
const isVerifiedContract = signal?.isVerified && signal?.source?.type === 'LEASE_CONTRACT'
|
||||||
|
const probPct = signal ? Math.round(signal.probability * 100) : null
|
||||||
|
const credLabel = signal ? (CREDIBILITY_LABELS[signal.source.credibility] ?? signal.source.credibility) : null
|
||||||
|
|
||||||
|
// Approximate signal deduction for display purposes
|
||||||
|
const signalDeduction = signal && !isVerifiedContract
|
||||||
|
? Math.round((1 - signal.probability) * sb.hardMatchScore * 0.18)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Schritt 1 — Kriterien-Match */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
||||||
|
Schritt 1 — Kriterien-Match
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25, lineHeight: 1.4 }}>
|
||||||
|
Wie gut passt die Fläche, wenn das Signal eintrifft?
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{match.positiveFactors.map((f, i) => (
|
||||||
|
<Box key={i} sx={{ mb: 1.25 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1a7a4a', ml: 1 }}>{f.score}/100</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={f.score}
|
||||||
|
color={scoreColor(f.score)}
|
||||||
|
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
|
||||||
|
{f.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(match.negativeFactors ?? []).map((f, i) => (
|
||||||
|
<Box key={i} sx={{ mb: 1.25 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
||||||
|
<X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', ml: 1 }}>{f.score}/100</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={f.score}
|
||||||
|
color="error"
|
||||||
|
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
|
||||||
|
{f.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>Basis-Score</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e293b' }}>{sb.hardMatchScore}/100</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* Schritt 2 — Verlässlichkeit */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
||||||
|
Schritt 2 — Verlässlichkeit des Signals
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isVerifiedContract ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
|
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
|
||||||
|
Vertragsende aus ERP verifiziert — keine Schätzung
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
|
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
|
||||||
|
Verwaltung hat Freigabe erteilt
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f0fdf4', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #bbf7d0' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>Signal-Abschlag</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1a7a4a' }}>keiner</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{probPct !== null && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit: {probPct}%</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#c0392b' }}>–{Math.round(signalDeduction * 0.7)} Pkt.</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{credLabel && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Quellenqualität: {credLabel}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>–{Math.round(signalDeduction * 0.3)} Pkt.</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#fff7ed', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #fed7aa' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>Signal-Abschlag</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#d97706' }}>–{signalDeduction} Punkte</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
|
{/* Gesamt */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sb.totalScore}/100
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
taxCalculatorUrl?: string
|
taxCalculatorUrl?: string
|
||||||
|
isFuture?: boolean
|
||||||
|
signal?: FutureSignal | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScoreBreakdownPanel({ match, taxCalculatorUrl }: Props) {
|
export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: Props) {
|
||||||
const sb = match.scoreBreakdown
|
const sb = match.scoreBreakdown
|
||||||
|
|
||||||
const hardColor: 'success' | 'warning' | 'error' =
|
if (isFuture) {
|
||||||
sb.hardMatchScore >= 70 ? 'success' : sb.hardMatchScore >= 50 ? 'warning' : 'error'
|
return (
|
||||||
const softColor: 'success' | 'warning' | 'error' =
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
sb.softFactorScore >= 70 ? 'success' : sb.softFactorScore >= 50 ? 'warning' : 'error'
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Bewertungsherleitung</Typography>
|
||||||
|
<SignalScoreDerivation match={match} signal={signal ?? null} />
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardColor = scoreColor(sb.hardMatchScore)
|
||||||
|
const softColor = scoreColor(sb.softFactorScore)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
|
|||||||
@@ -15,29 +15,12 @@ import { getCityIntelligence } from '../../lib/locationIntelligence'
|
|||||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
|
|
||||||
function buildReasons(positiveFactors: ScoreFactor[]): MatchCardReason[] {
|
function buildReasons(positiveFactors: ScoreFactor[]): MatchCardReason[] {
|
||||||
const reasons: MatchCardReason[] = []
|
return positiveFactors.slice(0, 3).map(f => ({
|
||||||
|
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
|
||||||
const hardFact = positiveFactors.find(f => HARD_CRITERIA.has(f.criterion))
|
label: capitalize(f.criterion),
|
||||||
const softFact = positiveFactors.find(f => !HARD_CRITERIA.has(f.criterion))
|
explanation: f.explanation,
|
||||||
|
score: f.score,
|
||||||
if (hardFact) {
|
}))
|
||||||
reasons.push({
|
|
||||||
type: 'HARD_FACT',
|
|
||||||
label: capitalize(hardFact.criterion),
|
|
||||||
explanation: hardFact.explanation,
|
|
||||||
score: hardFact.score,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (softFact) {
|
|
||||||
reasons.push({
|
|
||||||
type: 'SOFT_FACTOR',
|
|
||||||
label: capitalize(softFact.criterion),
|
|
||||||
explanation: softFact.explanation,
|
|
||||||
score: softFact.score,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return reasons
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(s: string): string {
|
function capitalize(s: string): string {
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function confColor(c: number) {
|
|
||||||
return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
function dqColor(q: number) {
|
|
||||||
return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MatchDetail() {
|
export default function MatchDetail() {
|
||||||
const { matchId } = useParams<{ matchId: string }>()
|
const { matchId } = useParams<{ matchId: string }>()
|
||||||
@@ -108,10 +102,6 @@ export default function MatchDetail() {
|
|||||||
const location = city
|
const location = city
|
||||||
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
|
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
|
||||||
: signal?.locationHint ?? '–'
|
: signal?.locationHint ?? '–'
|
||||||
const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
|
|
||||||
const confPct = Math.round(match.confidenceLevel * 100)
|
|
||||||
const dqPct = Math.round(dqScore * 100)
|
|
||||||
|
|
||||||
// Tax calculator — only shown when AI evaluated taxEnvironment
|
// Tax calculator — only shown when AI evaluated taxEnvironment
|
||||||
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
||||||
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
|
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
|
||||||
@@ -223,16 +213,6 @@ export default function MatchDetail() {
|
|||||||
{property?.assetType && (
|
{property?.assetType && (
|
||||||
<Chip label={property.assetType} size="small" variant="outlined" sx={{ fontSize: '0.72rem' }} />
|
<Chip label={property.assetType} size="small" variant="outlined" sx={{ fontSize: '0.72rem' }} />
|
||||||
)}
|
)}
|
||||||
<Chip
|
|
||||||
label={`${confPct}% Konfidenz`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: confColor(match.confidenceLevel), color: 'white', fontSize: '0.72rem' }}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
label={`DQ ${dqPct}%`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: dqColor(dqScore), color: 'white', fontSize: '0.72rem' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -343,7 +323,7 @@ export default function MatchDetail() {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} />
|
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
||||||
<NextActionsPanel
|
<NextActionsPanel
|
||||||
match={match}
|
match={match}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
|
|||||||
Reference in New Issue
Block a user