feat: F012 match detail view

Full decision analysis page at /demand/results/:matchId:
- MatchDetailHeader: score, badges, CTA (compare/shortlist), back button
- ExecutiveSummaryPanel: 4-row summary (fit, top reason, tradeoff, next step)
- PropertyOverviewPanel: key facts grid; signal-aware for FUTURE_AVAILABILITY
- NeedAlignmentPanel: comparison table with fit indicators (MATCH/PARTIAL/NO_MATCH)
- ScoreBreakdownPanel: hard/soft scores + modifiers + total (sidebar)
- TradeoffPanel: severity-coded list with mitigation hints
- RiskPanel: risks sorted CRITICAL→LOW with category chips
- MissingInformationPanel: priority-grouped with per-item CTAs
- SourceProvenancePanel: source type, label, URL, freshness
- FutureAvailabilityContextPanel: mandatory disclaimer + signal metadata
- NextActionsPanel: engine-ranked actions + standard actions (sidebar)
- Details button on result cards now navigates to match detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 13:55:46 +02:00
parent ea221def74
commit 7cf8d8ba72
16 changed files with 1115 additions and 2 deletions
@@ -0,0 +1,113 @@
import { Alert, Box, Button, Chip, Paper, Typography } from '@mui/material'
import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
function confColor(c: number): string {
return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
}
function dqColor(q: number): string {
return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
}
interface Props {
match: Match
property: Property | null
signal: FutureSignal | null
onBack: () => void
onCompare: () => void
onShortlist: () => void
}
export function MatchDetailHeader({ match, property, signal, onBack, onCompare, onShortlist }: Props) {
const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '', color: '#64748b' }
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? ''
const isFuture = match.resultType === 'FUTURE_AVAILABILITY'
const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
const confPct = Math.round(match.confidenceLevel * 100)
const dqPct = Math.round(dqScore * 100)
const location = property?.location?.city
? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}`
: signal?.locationHint ?? ''
const source = property?.sourceLabel ?? property?.sourceMeta?.sourceLabel ?? ''
const availability = property?.availabilityDate ?? (signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
return (
<Paper sx={{ p: 3, mb: 2 }}>
{isFuture && (
<Alert severity="warning" sx={{ mb: 2 }}>
Probabilistisches Signal keine bestätigte Fläche. Alle Angaben sind Schätzungen.
</Alert>
)}
<Button
startIcon={<ArrowLeft size={16} />}
onClick={onBack}
size="small"
sx={{ mb: 1.5, color: '#64748b' }}
>
Zurück zu Resultaten
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{location}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600 }} />
{property?.assetType && (
<Chip label={property.assetType} size="small" variant="outlined" />
)}
<Chip
label={`${confPct}% Konfidenz`}
size="small"
sx={{ bgcolor: confColor(match.confidenceLevel), color: 'white' }}
/>
<Chip
label={`DQ ${dqPct}%`}
size="small"
sx={{ bgcolor: dqColor(dqScore), color: 'white' }}
/>
{availability && <Chip label={availability} size="small" variant="outlined" />}
{source !== '' && (
<Typography variant="caption" color="text.secondary">Quelle: {source}</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 1.5 }}>
<MatchScoreDisplay score={match.matchScore} size="lg" />
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Bookmark size={14} />}
onClick={onShortlist}
>
Shortlist
</Button>
<Button
variant="contained"
size="small"
startIcon={<Columns2 size={14} />}
onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleichen
</Button>
</Box>
</Box>
</Box>
</Paper>
)
}