feat: F011 match card system

- MatchCardViewModel: typed view model decoupling card from raw API data
- Sub-components: MatchCardHeader, MatchScoreDisplay, MatchReasonList, TradeoffList, MatchDataQualitySummary, MatchActionToolbar, MatchCardSkeleton, MatchCardRestrictedState
- 4 variants: MatchCardCompact, MatchCardExpanded, MatchCardReview, MatchCardCompareMini
- MatchCard: main entry point with variant prop
- matchCardAdapter: buildMatchCardViewModel() converts UnifiedMatchResult -> ViewModel
- UnifiedResultCard: now thin wrapper using MatchCardCompact via adapter
- FUTURE_AVAILABILITY always shows non-dismissable disclaimer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 13:39:35 +02:00
parent 307b450807
commit ea221def74
17 changed files with 972 additions and 147 deletions
@@ -0,0 +1,40 @@
import { Box, Button } from '@mui/material'
import type { MatchCardAction } from './MatchCardViewModel'
const MUI_VARIANT: Record<string, 'contained' | 'outlined'> = {
primary: 'contained',
secondary: 'outlined',
danger: 'outlined',
}
interface Props {
actions: MatchCardAction[]
compact?: boolean
}
export function MatchActionToolbar({ actions }: Props) {
if (actions.length === 0) return null
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.75, flexWrap: 'wrap' }}>
{actions.map(action => (
<Button
key={action.id}
size="small"
variant={MUI_VARIANT[action.variant ?? 'secondary']}
disabled={action.disabled}
onClick={action.onClick}
sx={
action.variant === 'primary'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }
: action.variant === 'danger'
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
: {}
}
>
{action.label}
</Button>
))}
</Box>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { MatchCardViewModel, MatchCardVariant } from './MatchCardViewModel'
import { MatchCardCompact } from './MatchCardCompact'
import { MatchCardExpanded } from './MatchCardExpanded'
import { MatchCardReview } from './MatchCardReview'
import { MatchCardCompareMini } from './MatchCardCompareMini'
import { MatchCardSkeleton } from './MatchCardSkeleton'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
interface Props {
viewModel: MatchCardViewModel
variant?: MatchCardVariant
isLoading?: boolean
onRemoveCompare?: () => void
}
export function MatchCard({ viewModel, variant = 'compact', isLoading, onRemoveCompare }: Props) {
if (isLoading) {
return (
<MatchCardSkeleton
variant={variant === 'compare-mini' ? 'compare-mini' : variant === 'expanded' ? 'expanded' : 'compact'}
/>
)
}
if (viewModel.isRestricted) return <MatchCardRestrictedState />
switch (variant) {
case 'expanded':
return <MatchCardExpanded vm={viewModel} />
case 'review':
return <MatchCardReview vm={viewModel} />
case 'compare-mini':
return <MatchCardCompareMini vm={viewModel} onRemove={onRemoveCompare} />
default:
return <MatchCardCompact vm={viewModel} />
}
}
@@ -0,0 +1,106 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
}
export function MatchCardCompact({ vm }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const borderColor = vm.isCompareSelected
? '#7c3aed'
: vm.isSelected
? '#1e3a5f'
: 'transparent'
return (
<Card
sx={{
p: 2.5,
mb: 1.5,
border: `2px solid ${borderColor}`,
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'border-color 0.15s',
}}
>
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
{vm.disclaimer}
</Alert>
)}
{vm.isReviewRequired && (
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
Manuelle Überprüfung erforderlich
</Alert>
)}
{/* Header: score → type → confidence → availability → risk */}
<MatchCardHeader vm={vm} compact />
{/* Title + location (max 2 lines) */}
<Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{vm.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{vm.locationLabel}
</Typography>
</Box>
{vm.explainabilitySummary && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{vm.explainabilitySummary}
</Typography>
)}
</Box>
<Divider sx={{ mb: 1.25 }} />
{/* Top reason only */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}>
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
</Box>
)}
{/* Top tradeoff only (compact) */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ mb: 1 }}>
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
</Box>
)}
{/* Data quality — only critical warning in compact mode */}
<MatchDataQualitySummary
dataQualityScore={vm.dataQualityScore}
missingData={vm.missingData}
compact
/>
<Box sx={{ mt: 1.25 }}>
<MatchActionToolbar actions={vm.actions} compact />
</Box>
</Card>
)
}
@@ -0,0 +1,68 @@
import { Box, Card, Chip, Divider, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Extern', color: '#d97706' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
}
interface Props {
vm: MatchCardViewModel
onRemove?: () => void
}
export function MatchCardCompareMini({ vm, onRemove }: Props) {
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const topReason = vm.reasons[0]
return (
<Card sx={{ p: 1.5, minWidth: 180, maxWidth: 220, position: 'relative', flexShrink: 0 }}>
{onRemove && (
<IconButton
size="small"
onClick={onRemove}
sx={{ position: 'absolute', top: 4, right: 4, p: 0.25 }}
>
<X size={14} />
</IconButton>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5, pr: 2 }}>
<MatchScoreDisplay score={vm.matchScore} size="sm" />
<Chip
label={rt.label}
size="small"
sx={{ bgcolor: rt.color, color: 'white', fontSize: 10, height: 18 }}
/>
</Box>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }} noWrap>
{vm.title}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{vm.locationLabel}
</Typography>
{topReason && (
<>
<Divider sx={{ my: 0.75 }} />
<Typography
variant="caption"
color="text.secondary"
sx={{
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{topReason.explanation}
</Typography>
</>
)}
</Card>
)
}
@@ -0,0 +1,102 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
}
export function MatchCardExpanded({ vm }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
return (
<Card sx={{ p: 3, mb: 2 }}>
{/* FUTURE_AVAILABILITY disclaimer — mandatory */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 2 }}>
{vm.disclaimer}
</Alert>
)}
{vm.isReviewRequired && (
<Alert severity="info" sx={{ mb: 2 }}>
Manuelle Überprüfung erforderlich
</Alert>
)}
<MatchCardHeader vm={vm} />
{/* Primary summary zone */}
<Box sx={{ mt: 2, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{vm.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<MapPin size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">{vm.locationLabel}</Typography>
</Box>
{vm.availabilityLabel && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
Verfügbar: {vm.availabilityLabel}
</Typography>
)}
{vm.explainabilitySummary && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1, borderLeft: '3px solid #e2e8f0' }}
>
{vm.explainabilitySummary}
</Typography>
)}
</Box>
<Divider sx={{ mb: 2 }} />
{/* Why it matches — all 3 reasons */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 2 }}>
<MatchReasonList reasons={vm.reasons} maxItems={3} />
</Box>
)}
{/* Tradeoffs — up to 3 */}
{vm.tradeoffs.length > 0 && (
<>
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ mb: 2 }}>
<TradeoffList tradeoffs={vm.tradeoffs} maxItems={3} />
</Box>
</>
)}
{/* Data quality — full view */}
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ mb: 2 }}>
<MatchDataQualitySummary
dataQualityScore={vm.dataQualityScore}
missingData={vm.missingData}
/>
</Box>
{vm.sourceLabel && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
Quelle: {vm.sourceLabel}
{vm.externalUrl && (
<a href={vm.externalUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 4 }}>
</a>
)}
</Typography>
)}
<MatchActionToolbar actions={vm.actions} />
</Card>
)
}
@@ -0,0 +1,60 @@
import { Box, Chip } from '@mui/material'
import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel'
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 confidenceColor(score: number): string {
if (score >= 0.75) return '#1a7a4a'
if (score >= 0.55) return '#d97706'
return '#c0392b'
}
interface Props {
vm: MatchCardViewModel
compact?: boolean
}
export function MatchCardHeader({ vm, compact }: Props) {
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.length > 0 ? vm.risks[0].level : undefined
const showRiskBadge = topRisk && topRisk !== 'LOW'
const riskLabel = topRisk === 'CRITICAL' ? 'Kritisch' : topRisk === 'HIGH' ? 'Hohes Risiko' : 'Mittleres Risiko'
const riskColor: 'error' | 'warning' = (topRisk === 'HIGH' || topRisk === 'CRITICAL') ? 'error' : 'warning'
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
{/* Score — leftmost, most prominent */}
<MatchScoreDisplay score={vm.matchScore} size={compact ? 'sm' : 'md'} />
{/* Badges: resultType → assetType → confidence → availability → risk */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<Chip
label={rt.label}
size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
/>
{vm.assetType && (
<Chip label={vm.assetType} size="small" variant="outlined" sx={{ fontSize: 11 }} />
)}
<Chip
label={`${confPct}% Konfidenz`}
size="small"
sx={{ bgcolor: confidenceColor(vm.confidenceScore), color: 'white', fontSize: 11 }}
/>
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 11 }} />
)}
{showRiskBadge && (
<Chip label={riskLabel} size="small" color={riskColor} variant="outlined" />
)}
</Box>
</Box>
)
}
@@ -0,0 +1,25 @@
import { Box, Card, Typography } from '@mui/material'
import { Lock } from 'lucide-react'
interface Props {
title?: string
message?: string
}
export function MatchCardRestrictedState({ title, message }: Props) {
return (
<Card sx={{ p: 2.5, mb: 1.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.5 }}>
<Lock size={20} color="#94a3b8" />
<Box>
<Typography variant="subtitle2" color="text.secondary" sx={{ fontWeight: 600 }}>
{title ?? 'Zugriff eingeschränkt'}
</Typography>
<Typography variant="caption" color="text.secondary">
{message ?? 'Sie haben keine Berechtigung, dieses Match einzusehen.'}
</Typography>
</Box>
</Box>
</Card>
)
}
@@ -0,0 +1,100 @@
import { Alert, Box, Card, Chip, Divider, Typography } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import type { MatchCardViewModel } from './MatchCardViewModel'
import type { Risk } from '../../domain/match'
function riskChipColor(level: Risk['level']): 'error' | 'warning' | 'success' {
if (level === 'CRITICAL' || level === 'HIGH') return 'error'
if (level === 'MEDIUM') return 'warning'
return 'success'
}
interface Props {
vm: MatchCardViewModel
}
export function MatchCardReview({ vm }: Props) {
return (
<Card sx={{ p: 2.5, mb: 1.5, border: '1px solid #fde68a', bgcolor: '#fffbeb' }}>
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{vm.disclaimer}
</Alert>
)}
<MatchCardHeader vm={vm} />
<Box sx={{ mt: 1.5, mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{vm.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{vm.locationLabel}
</Typography>
{vm.explainabilitySummary && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.75, fontStyle: 'italic' }}>
{vm.explainabilitySummary}
</Typography>
)}
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Reasons with scores */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<MatchReasonList reasons={vm.reasons} maxItems={3} />
</Box>
)}
{/* Risks — prominent in review context */}
{vm.risks.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Risiken
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{vm.risks.map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<AlertTriangle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}>
<Chip
label={r.category}
size="small"
color={riskChipColor(r.level)}
variant="outlined"
/>
</Box>
<Typography variant="caption" color="text.secondary">
{r.description}
</Typography>
{r.mitigation && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontStyle: 'italic' }}>
{r.mitigation}
</Typography>
)}
</Box>
</Box>
))}
</Box>
</Box>
)}
{/* Missing data — full detail for review */}
<Box sx={{ mb: 1.5 }}>
<MatchDataQualitySummary
dataQualityScore={vm.dataQualityScore}
missingData={vm.missingData}
/>
</Box>
<Divider sx={{ mb: 1.5 }} />
<MatchActionToolbar actions={vm.actions} />
</Card>
)
}
@@ -0,0 +1,40 @@
import { Box, Card, Skeleton } from '@mui/material'
interface Props {
variant?: 'compact' | 'expanded' | 'compare-mini'
}
export function MatchCardSkeleton({ variant = 'compact' }: Props) {
if (variant === 'compare-mini') {
return (
<Card sx={{ p: 1.5, minWidth: 180 }}>
<Skeleton variant="text" width={60} sx={{ fontSize: '1.25rem', mb: 0.5 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="text" width={80} />
</Card>
)
}
return (
<Card sx={{ p: 2.5, mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Skeleton variant="text" width={60} sx={{ fontSize: '1.75rem' }} />
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Skeleton variant="rounded" width={110} height={22} />
<Skeleton variant="rounded" width={90} height={22} />
<Skeleton variant="rounded" width={80} height={22} />
</Box>
</Box>
<Skeleton variant="text" width="55%" sx={{ mb: 0.5 }} />
<Skeleton variant="text" width="35%" sx={{ mb: 1.5 }} />
{variant === 'expanded' && (
<>
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="rounded" height={40} sx={{ mt: 1, mb: 1 }} />
</>
)}
<Skeleton variant="rounded" height={32} sx={{ mt: 1 }} />
</Card>
)
}
@@ -0,0 +1,56 @@
import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem } from '../../domain/match'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
export type MatchCardActionType =
| 'OPEN_DETAIL'
| 'ADD_COMPARE'
| 'SAVE_SHORTLIST'
| 'SEND_REVIEW'
| 'REJECT'
| 'REQUEST_DATA'
export interface MatchCardAction {
id: string
label: string
actionType: MatchCardActionType
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
onClick: () => void
}
export interface MatchCardReason {
type: 'HARD_FACT' | 'SOFT_FACTOR' | 'STRATEGIC'
label: string
explanation: string
score: number
}
export interface MatchCardViewModel {
id: string
title: string
resultType: ResultType
assetType?: string
matchScore: number
confidenceScore: number // 01
dataQualityScore: number // 01
locationLabel: string
availabilityLabel?: string
sourceLabel?: string
externalUrl?: string
reasons: MatchCardReason[] // max 3: HARD_FACT, SOFT_FACTOR, STRATEGIC
tradeoffs: TradeOff[]
risks: Risk[]
missingData: MissingDataItem[]
actions: MatchCardAction[]
disclaimer?: string // required for FUTURE_AVAILABILITY
explainabilitySummary?: string
// States
isSelected?: boolean
isCompareSelected?: boolean
isRestricted?: boolean
isReviewRequired?: boolean
isStaleData?: boolean
}
@@ -0,0 +1,51 @@
import { Alert, Box, LinearProgress, Typography } from '@mui/material'
import type { MissingDataItem } from '../../domain/match'
interface Props {
dataQualityScore: number
missingData: MissingDataItem[]
compact?: boolean
}
export function MatchDataQualitySummary({ dataQualityScore, missingData, compact }: Props) {
const pct = Math.round(dataQualityScore * 100)
const hasCritical = missingData.some(m => m.importance === 'CRITICAL')
const criticalItem = missingData.find(m => m.importance === 'CRITICAL')
const progressColor: 'success' | 'warning' | 'error' = pct >= 80 ? 'success' : pct >= 60 ? 'warning' : 'error'
if (compact) {
if (!hasCritical) return null
return (
<Alert severity="warning" sx={{ py: 0.25, px: 1, mb: 0.5, '& .MuiAlert-message': { fontSize: 11 } }}>
Kritische Daten fehlen: {criticalItem?.field}
</Alert>
)
}
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Datenqualität
</Typography>
{hasCritical && (
<Alert severity="warning" sx={{ py: 0.25, px: 1, mb: 0.75, '& .MuiAlert-message': { fontSize: 11 } }}>
Kritische Daten fehlen: {criticalItem?.field}
</Alert>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1 }}>
<LinearProgress variant="determinate" value={pct} sx={{ height: 6, borderRadius: 3 }} color={progressColor} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 32 }}>
{pct}%
</Typography>
</Box>
{missingData.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{missingData.length} fehlende{missingData.length > 1 ? '' : 's'} Feld{missingData.length > 1 ? 'er' : ''}
{criticalItem ? ` · kritisch: ${criticalItem.field}` : ''}
</Typography>
)}
</Box>
)
}
@@ -0,0 +1,38 @@
import { Box, Typography } from '@mui/material'
import { CheckCircle2 } from 'lucide-react'
import type { MatchCardReason } from './MatchCardViewModel'
interface Props {
reasons: MatchCardReason[]
maxItems?: number
}
export function MatchReasonList({ reasons, maxItems = 3 }: Props) {
if (reasons.length === 0) return null
const shown = reasons.slice(0, maxItems)
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Warum dieses Match
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{shown.map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={14} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>
{r.label}
</Typography>
{r.explanation && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{r.explanation}
</Typography>
)}
</Box>
</Box>
))}
</Box>
</Box>
)
}
@@ -0,0 +1,25 @@
import { Box, Typography } from '@mui/material'
interface Props {
score: number
size?: 'sm' | 'md' | 'lg'
}
function scoreColor(score: number): string {
if (score >= 78) return '#1a7a4a'
if (score >= 52) return '#d97706'
return '#c0392b'
}
const FONT_SIZES: Record<string, string> = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' }
export function MatchScoreDisplay({ score, size = 'md' }: Props) {
return (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.25, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: scoreColor(score), lineHeight: 1 }}>
{score}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
</Box>
)
}
@@ -0,0 +1,56 @@
import { Box, Typography } from '@mui/material'
import { ArrowLeftRight } from 'lucide-react'
import type { TradeOff } from '../../domain/match'
function severityColor(severity: TradeOff['severity']): string {
if (severity === 'HIGH') return '#d97706'
if (severity === 'MEDIUM') return '#92400e'
return '#64748b'
}
interface Props {
tradeoffs: TradeOff[]
maxItems?: number
compact?: boolean
}
export function TradeoffList({ tradeoffs, maxItems = 3, compact }: Props) {
if (tradeoffs.length === 0) return null
const shown = tradeoffs.slice(0, maxItems)
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Abwägungen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{shown.map((t, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<ArrowLeftRight size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
{compact ? (
<Typography variant="caption" sx={{ color: severityColor(t.severity) }}>
{t.concern}
</Typography>
) : (
<>
<Typography variant="caption" sx={{ fontWeight: 600, color: severityColor(t.severity) }}>
{t.criterion}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{t.concern}
</Typography>
{t.mitigation && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontStyle: 'italic' }}>
{t.mitigation}
</Typography>
)}
</>
)}
</Box>
</Box>
))}
</Box>
</Box>
)
}
+20
View File
@@ -0,0 +1,20 @@
export { MatchCard } from './MatchCard'
export { MatchCardCompact } from './MatchCardCompact'
export { MatchCardExpanded } from './MatchCardExpanded'
export { MatchCardReview } from './MatchCardReview'
export { MatchCardCompareMini } from './MatchCardCompareMini'
export { MatchCardHeader } from './MatchCardHeader'
export { MatchScoreDisplay } from './MatchScoreDisplay'
export { MatchReasonList } from './MatchReasonList'
export { TradeoffList } from './TradeoffList'
export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export type {
MatchCardViewModel,
MatchCardVariant,
MatchCardAction,
MatchCardActionType,
MatchCardReason,
} from './MatchCardViewModel'
+38 -147
View File
@@ -1,16 +1,7 @@
import { Alert, Box, Button, Card, Chip, Divider, Stack, Typography } from '@mui/material' import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { Banknote, Bookmark, Calendar, Columns2, MapPin, Maximize2 } from 'lucide-react' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import type { Property } from '../../domain/property' import type { MatchCardAction } from '../match-card/MatchCardViewModel'
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 { interface Props {
result: UnifiedMatchResult result: UnifiedMatchResult
@@ -19,141 +10,41 @@ interface Props {
} }
export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) { export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
const { match, matchScore, resultType } = result const isFuture = result.resultType === 'FUTURE_AVAILABILITY'
const compareId = !isFuture
? (result as { property: { id: string } }).property.id
: ''
const isFuture = resultType === 'FUTURE_AVAILABILITY' const actions: MatchCardAction[] = [
const property: Property | undefined = !isFuture ? (result as { property: Property }).property : undefined {
const signal: FutureSignal | undefined = isFuture ? (result as { signal: FutureSignal }).signal : undefined id: 'shortlist',
label: 'Shortlist',
actionType: 'SAVE_SHORTLIST',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
...(!isFuture
? [
{
id: 'compare',
label: 'Vergleichen',
actionType: 'ADD_COMPARE' as const,
variant: (isInCompare ? 'primary' : 'secondary') as 'primary' | 'secondary',
onClick: () => onCompare(compareId),
},
]
: []),
{
id: 'details',
label: 'Details',
actionType: 'OPEN_DETAIL',
variant: 'primary',
disabled: true,
onClick: () => {},
},
]
const compareId = property?.id ?? '' const vm = buildMatchCardViewModel(result, actions)
return <MatchCardCompact vm={vm} />
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>
)
} }
+110
View File
@@ -0,0 +1,110 @@
import type {
UnifiedMatchResult,
VerifiedPortfolioResult,
ExternalMarketResult,
FutureAvailabilityResult,
} from '../../domain/unifiedResult'
import type { ScoreFactor } from '../../domain/match'
import type {
MatchCardViewModel,
MatchCardAction,
MatchCardReason,
} from '../../components/match-card/MatchCardViewModel'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
function buildReasons(positiveFactors: ScoreFactor[]): MatchCardReason[] {
const reasons: MatchCardReason[] = []
const hardFact = positiveFactors.find(f => HARD_CRITERIA.has(f.criterion))
const softFact = positiveFactors.find(f => !HARD_CRITERIA.has(f.criterion))
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 {
return s.charAt(0).toUpperCase() + s.slice(1)
}
export function buildMatchCardViewModel(
result: UnifiedMatchResult,
actions: MatchCardAction[],
): MatchCardViewModel {
const { match, matchScore, resultType } = result
const isFuture = resultType === 'FUTURE_AVAILABILITY'
const property = !isFuture
? (result as VerifiedPortfolioResult | ExternalMarketResult).property
: undefined
const signal = isFuture
? (result as FutureAvailabilityResult).signal
: undefined
const city = property?.location?.city
const district = property?.location?.district
const locationLabel = city
? `${city}${district ? `, ${district}` : ''}`
: signal?.locationHint ?? ''
const availabilityLabel =
property?.availabilityDate ??
(signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
const sourceLabel =
property?.sourceLabel ??
property?.sourceMeta?.sourceLabel ??
property?.sourceMeta?.sourceType
const externalUrl = property?.sourceUrl ?? property?.sourceMeta?.sourceUrl
const reasons = buildReasons(match.positiveFactors)
const dataQualityScore =
property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
return {
id: result.matchId,
title:
property?.title ??
signal?.companyName ??
signal?.locationHint ??
'',
resultType,
assetType: property?.assetType,
matchScore,
confidenceScore: match.confidenceLevel,
dataQualityScore,
locationLabel,
availabilityLabel,
sourceLabel,
externalUrl,
reasons,
tradeoffs: match.tradeoffs ?? [],
risks: match.risks ?? [],
missingData: match.missingData ?? [],
actions,
disclaimer: isFuture
? (signal?.disclaimer ?? 'Probabilistisches Signal kein bestätigtes Objekt')
: undefined,
explainabilitySummary: match.explainabilitySummary,
isReviewRequired: match.status === 'PENDING_REVIEW',
isStaleData: false,
}
}