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:
@@ -0,0 +1,153 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, XCircle, Minus } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
|
||||
type FitStatus = 'MATCH' | 'NO_MATCH' | 'PARTIAL' | 'UNKNOWN'
|
||||
|
||||
interface AlignmentRow {
|
||||
label: string
|
||||
needValue: string
|
||||
resultValue: string
|
||||
fit: FitStatus
|
||||
}
|
||||
|
||||
function fitIcon(fit: FitStatus) {
|
||||
if (fit === 'MATCH') return <CheckCircle2 size={16} color="#1a7a4a" />
|
||||
if (fit === 'NO_MATCH') return <XCircle size={16} color="#c0392b" />
|
||||
if (fit === 'PARTIAL') return <Minus size={16} color="#d97706" />
|
||||
return <Minus size={16} color="#94a3b8" />
|
||||
}
|
||||
|
||||
function fitColor(fit: FitStatus): string {
|
||||
if (fit === 'MATCH') return '#f0fdf4'
|
||||
if (fit === 'NO_MATCH') return '#fef2f2'
|
||||
if (fit === 'PARTIAL') return '#fffbeb'
|
||||
return '#f8fafc'
|
||||
}
|
||||
|
||||
function buildRows(need: Need, property: Property): AlignmentRow[] {
|
||||
const rows: AlignmentRow[] = []
|
||||
|
||||
// Area
|
||||
const areaSqm = property.areaSqm
|
||||
const areaFit: FitStatus = areaSqm >= need.requiredArea.min && areaSqm <= need.requiredArea.max
|
||||
? 'MATCH'
|
||||
: areaSqm >= need.requiredArea.min * 0.85
|
||||
? 'PARTIAL'
|
||||
: 'NO_MATCH'
|
||||
rows.push({
|
||||
label: 'Fläche',
|
||||
needValue: `${need.requiredArea.min}–${need.requiredArea.max} m²`,
|
||||
resultValue: `${areaSqm} m²`,
|
||||
fit: areaFit,
|
||||
})
|
||||
|
||||
// Location
|
||||
const locationFit: FitStatus = need.preferredLocations.some(
|
||||
l => l.toLowerCase() === property.location.city.toLowerCase() ||
|
||||
l.toLowerCase() === property.location.district?.toLowerCase()
|
||||
) ? 'MATCH' : 'PARTIAL'
|
||||
rows.push({
|
||||
label: 'Standort',
|
||||
needValue: need.preferredLocations.join(', ') || '–',
|
||||
resultValue: property.location.city,
|
||||
fit: locationFit,
|
||||
})
|
||||
|
||||
// Budget
|
||||
const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
|
||||
? 'MATCH'
|
||||
: property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
|
||||
? 'PARTIAL'
|
||||
: 'NO_MATCH'
|
||||
rows.push({
|
||||
label: 'Budget',
|
||||
needValue: `max. CHF ${need.budgetRange.maxPerSqm}/m²`,
|
||||
resultValue: `CHF ${property.rentPricePerSqm}/m²`,
|
||||
fit: budgetFit,
|
||||
})
|
||||
|
||||
// Timing
|
||||
const availDate = property.availabilityDate
|
||||
const latestMoveIn = need.timing.latestMoveIn
|
||||
const timingFit: FitStatus = !availDate ? 'UNKNOWN'
|
||||
: availDate <= latestMoveIn ? 'MATCH' : 'PARTIAL'
|
||||
rows.push({
|
||||
label: 'Verfügbarkeit',
|
||||
needValue: `bis ${need.timing.latestMoveIn}`,
|
||||
resultValue: availDate || 'unbekannt',
|
||||
fit: timingFit,
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
need: Need | null
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function NeedAlignmentPanel({ match: _match, need, property }: Props) {
|
||||
if (!need || !property) return null
|
||||
|
||||
const rows = buildRows(need, property)
|
||||
const mustHaves = need.mustHaveCriteria ?? []
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Need-Alignment</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
Gesuch: {need.companyName} · {need.assetType}
|
||||
</Typography>
|
||||
|
||||
{/* Comparison table */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0, bgcolor: '#f8fafc', px: 1.5, py: 0.75, borderRadius: 1, mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: '0 0 130px' }}>Kriterium</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: 1 }}>Gesuch</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: 1 }}>Objekt</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, width: 28, textAlign: 'center' }}>Fit</Typography>
|
||||
</Box>
|
||||
{rows.map((row, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
bgcolor: fitColor(row.fit),
|
||||
borderRadius: 0.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, flex: '0 0 130px' }}>{row.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{row.needValue}</Typography>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>{row.resultValue}</Typography>
|
||||
<Box sx={{ width: 28, display: 'flex', justifyContent: 'center' }}>
|
||||
{fitIcon(row.fit)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Must-haves */}
|
||||
{mustHaves.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
||||
Must-have Kriterien
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{mustHaves.map((m, i) => (
|
||||
<Chip key={i} label={m.criterion} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user