Files
property-match/src/components/results/UnifiedResultCard.tsx
T
Benjamin Sutter 307b450807 feat: F010 unified result feed
Replace monolithic Results.tsx with decomposed component architecture:
- 8 new components in src/components/results/ (ResultTypeBadge, ResultConfidenceSummary, FeedEmptyState, FeedSkeleton, ResultFeedHeader, ResultFilterBar, UnifiedResultCard, UnifiedResultFeed)
- Reuses existing useUnifiedResults hook and compareStore
- FUTURE_AVAILABILITY cards always display non-dismissable disclaimer
- Filter by result type + sort by score/area/rent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:18:58 +02:00

160 lines
6.3 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 { Alert, Box, Button, Card, Chip, Divider, Stack, Typography } from '@mui/material'
import { Banknote, Bookmark, Calendar, Columns2, MapPin, Maximize2 } from 'lucide-react'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
import { ResultConfidenceSummary } from './ResultConfidenceSummary'
import { ResultTypeBadge } from './ResultTypeBadge'
function scoreColor(score: number): string {
if (score >= 78) return '#1a7a4a'
if (score >= 52) return '#d97706'
return '#c0392b'
}
interface Props {
result: UnifiedMatchResult
isInCompare: boolean
onCompare: (propertyId: string) => void
}
export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
const { match, matchScore, resultType } = result
const isFuture = resultType === 'FUTURE_AVAILABILITY'
const property: Property | undefined = !isFuture ? (result as { property: Property }).property : undefined
const signal: FutureSignal | undefined = isFuture ? (result as { signal: FutureSignal }).signal : undefined
const compareId = property?.id ?? ''
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? ''
const city = property?.location?.city ?? signal?.locationHint ?? ''
const areaSqm = property?.areaSqm ?? signal?.areaSqmEstimate
const rent = property?.rentPricePerSqm
const available = property?.availabilityDate
const dqScore = property?.dataQuality?.score
return (
<Card sx={{ p: 2.5, mb: 1.5 }}>
{/* FUTURE_AVAILABILITY disclaimer — always shown, non-dismissable */}
{isFuture && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
{signal?.disclaimer ?? 'Probabilistisches Signal kein bestätigtes Objekt'}
</Alert>
)}
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<ResultTypeBadge resultType={resultType} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>{title}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: scoreColor(matchScore) }}>
{matchScore}
</Typography>
<Typography variant="body2" color="text.secondary">/100</Typography>
</Box>
</Box>
{/* Key facts */}
<Stack direction="row" spacing={2.5} sx={{ mb: 1.5, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<MapPin size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">{city}</Typography>
</Box>
{areaSqm !== undefined && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Maximize2 size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">{areaSqm} m²</Typography>
</Box>
)}
{rent !== undefined && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Banknote size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">CHF {rent}/m²</Typography>
</Box>
)}
{available && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">{available}</Typography>
</Box>
)}
{isFuture && signal?.timeHorizonMonths && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">
~{signal.timeHorizonMonths} Monate · {Math.round(signal.probability * 100)}% Wahrscheinlichkeit
</Typography>
</Box>
)}
</Stack>
<Divider sx={{ mb: 1.5 }} />
{/* Positive factors */}
{match.positiveFactors.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }} color="text.secondary">
Positive Faktoren
</Typography>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{match.positiveFactors.slice(0, 3).map((f, i) => (
<Chip
key={i}
label={f.criterion}
size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontSize: 11 }}
/>
))}
</Stack>
</Box>
)}
{/* Tradeoffs */}
{(match.tradeoffs?.length ?? 0) > 0 && (
<Alert severity="warning" sx={{ py: 0.5, px: 1.5, mb: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>Abwägungen</Typography>
{match.tradeoffs.slice(0, 2).map((t, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block' }}>
{t.criterion}: {t.concern}
</Typography>
))}
</Alert>
)}
{/* Confidence + data quality */}
<Box sx={{ mb: 1.5 }}>
<ResultConfidenceSummary confidenceLevel={match.confidenceLevel} dataQualityScore={dqScore} />
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="outlined" size="small" startIcon={<Bookmark size={14} />} disabled>
Shortlist
</Button>
{!isFuture && (
<Button
variant={isInCompare ? 'contained' : 'outlined'}
size="small"
startIcon={<Columns2 size={14} />}
onClick={() => onCompare(compareId)}
sx={isInCompare ? { bgcolor: '#1e3a5f' } : {}}
>
Vergleichen
</Button>
)}
<Button
variant="contained"
size="small"
disabled
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Details
</Button>
</Box>
</Card>
)
}