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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 // 0–1
|
||||
dataQualityScore: number // 0–1
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Alert, Box, Button, Card, Chip, Divider, Stack, Typography } from '@mui/material'
|
||||
import { Banknote, Bookmark, Calendar, Columns2, MapPin, Maximize2 } from 'lucide-react'
|
||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||
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'
|
||||
}
|
||||
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
result: UnifiedMatchResult
|
||||
@@ -19,141 +10,41 @@ interface 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 property: Property | undefined = !isFuture ? (result as { property: Property }).property : undefined
|
||||
const signal: FutureSignal | undefined = isFuture ? (result as { signal: FutureSignal }).signal : undefined
|
||||
const actions: MatchCardAction[] = [
|
||||
{
|
||||
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 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>
|
||||
)
|
||||
const vm = buildMatchCardViewModel(result, actions)
|
||||
return <MatchCardCompact vm={vm} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user