diff --git a/src/components/match-card/MatchActionToolbar.tsx b/src/components/match-card/MatchActionToolbar.tsx new file mode 100644 index 0000000..d9161c2 --- /dev/null +++ b/src/components/match-card/MatchActionToolbar.tsx @@ -0,0 +1,40 @@ +import { Box, Button } from '@mui/material' +import type { MatchCardAction } from './MatchCardViewModel' + +const MUI_VARIANT: Record = { + primary: 'contained', + secondary: 'outlined', + danger: 'outlined', +} + +interface Props { + actions: MatchCardAction[] + compact?: boolean +} + +export function MatchActionToolbar({ actions }: Props) { + if (actions.length === 0) return null + + return ( + + {actions.map(action => ( + + ))} + + ) +} diff --git a/src/components/match-card/MatchCard.tsx b/src/components/match-card/MatchCard.tsx new file mode 100644 index 0000000..5b82e9e --- /dev/null +++ b/src/components/match-card/MatchCard.tsx @@ -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 ( + + ) + } + + if (viewModel.isRestricted) return + + switch (variant) { + case 'expanded': + return + case 'review': + return + case 'compare-mini': + return + default: + return + } +} diff --git a/src/components/match-card/MatchCardCompact.tsx b/src/components/match-card/MatchCardCompact.tsx new file mode 100644 index 0000000..423750e --- /dev/null +++ b/src/components/match-card/MatchCardCompact.tsx @@ -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 + + const borderColor = vm.isCompareSelected + ? '#7c3aed' + : vm.isSelected + ? '#1e3a5f' + : 'transparent' + + return ( + + {/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */} + {vm.disclaimer && ( + + {vm.disclaimer} + + )} + + {vm.isReviewRequired && ( + + Manuelle Überprüfung erforderlich + + )} + + {/* Header: score → type → confidence → availability → risk */} + + + {/* Title + location (max 2 lines) */} + + + {vm.title} + + + + + {vm.locationLabel} + + + {vm.explainabilitySummary && ( + + {vm.explainabilitySummary} + + )} + + + + + {/* Top reason only */} + {vm.reasons.length > 0 && ( + + + + )} + + {/* Top tradeoff only (compact) */} + {vm.tradeoffs.length > 0 && ( + + + + )} + + {/* Data quality — only critical warning in compact mode */} + + + + + + + ) +} diff --git a/src/components/match-card/MatchCardCompareMini.tsx b/src/components/match-card/MatchCardCompareMini.tsx new file mode 100644 index 0000000..5955298 --- /dev/null +++ b/src/components/match-card/MatchCardCompareMini.tsx @@ -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 = { + 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 ( + + {onRemove && ( + + + + )} + + + + + + + + {vm.title} + + + {vm.locationLabel} + + + {topReason && ( + <> + + + {topReason.explanation} + + + )} + + ) +} diff --git a/src/components/match-card/MatchCardExpanded.tsx b/src/components/match-card/MatchCardExpanded.tsx new file mode 100644 index 0000000..305ba19 --- /dev/null +++ b/src/components/match-card/MatchCardExpanded.tsx @@ -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 + + return ( + + {/* FUTURE_AVAILABILITY disclaimer — mandatory */} + {vm.disclaimer && ( + + {vm.disclaimer} + + )} + + {vm.isReviewRequired && ( + + Manuelle Überprüfung erforderlich + + )} + + + + {/* Primary summary zone */} + + + {vm.title} + + + + {vm.locationLabel} + + {vm.availabilityLabel && ( + + Verfügbar: {vm.availabilityLabel} + + )} + {vm.explainabilitySummary && ( + + {vm.explainabilitySummary} + + )} + + + + + {/* Why it matches — all 3 reasons */} + {vm.reasons.length > 0 && ( + + + + )} + + {/* Tradeoffs — up to 3 */} + {vm.tradeoffs.length > 0 && ( + <> + + + + + + )} + + {/* Data quality — full view */} + + + + + + {vm.sourceLabel && ( + + Quelle: {vm.sourceLabel} + {vm.externalUrl && ( + + ↗ + + )} + + )} + + + + ) +} diff --git a/src/components/match-card/MatchCardHeader.tsx b/src/components/match-card/MatchCardHeader.tsx new file mode 100644 index 0000000..488dff9 --- /dev/null +++ b/src/components/match-card/MatchCardHeader.tsx @@ -0,0 +1,60 @@ +import { Box, Chip } from '@mui/material' +import { MatchScoreDisplay } from './MatchScoreDisplay' +import type { MatchCardViewModel } from './MatchCardViewModel' + +const RESULT_TYPE_META: Record = { + 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 ( + + {/* Score — leftmost, most prominent */} + + + {/* Badges: resultType → assetType → confidence → availability → risk */} + + + {vm.assetType && ( + + )} + + {vm.availabilityLabel && ( + + )} + {showRiskBadge && ( + + )} + + + ) +} diff --git a/src/components/match-card/MatchCardRestrictedState.tsx b/src/components/match-card/MatchCardRestrictedState.tsx new file mode 100644 index 0000000..91033f2 --- /dev/null +++ b/src/components/match-card/MatchCardRestrictedState.tsx @@ -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 ( + + + + + + {title ?? 'Zugriff eingeschränkt'} + + + {message ?? 'Sie haben keine Berechtigung, dieses Match einzusehen.'} + + + + + ) +} diff --git a/src/components/match-card/MatchCardReview.tsx b/src/components/match-card/MatchCardReview.tsx new file mode 100644 index 0000000..fe63ee4 --- /dev/null +++ b/src/components/match-card/MatchCardReview.tsx @@ -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 ( + + {vm.disclaimer && ( + + {vm.disclaimer} + + )} + + + + + + {vm.title} + + + {vm.locationLabel} + + {vm.explainabilitySummary && ( + + {vm.explainabilitySummary} + + )} + + + + + {/* Reasons with scores */} + {vm.reasons.length > 0 && ( + + + + )} + + {/* Risks — prominent in review context */} + {vm.risks.length > 0 && ( + + + Risiken + + + {vm.risks.map((r, i) => ( + + + + + + + + {r.description} + + {r.mitigation && ( + + → {r.mitigation} + + )} + + + ))} + + + )} + + {/* Missing data — full detail for review */} + + + + + + + + ) +} diff --git a/src/components/match-card/MatchCardSkeleton.tsx b/src/components/match-card/MatchCardSkeleton.tsx new file mode 100644 index 0000000..77e3f70 --- /dev/null +++ b/src/components/match-card/MatchCardSkeleton.tsx @@ -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 ( + + + + + + ) + } + + return ( + + + + + + + + + + + + {variant === 'expanded' && ( + <> + + + + + )} + + + ) +} diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts new file mode 100644 index 0000000..20d8e5b --- /dev/null +++ b/src/components/match-card/MatchCardViewModel.ts @@ -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 +} diff --git a/src/components/match-card/MatchDataQualitySummary.tsx b/src/components/match-card/MatchDataQualitySummary.tsx new file mode 100644 index 0000000..efb44ac --- /dev/null +++ b/src/components/match-card/MatchDataQualitySummary.tsx @@ -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 ( + + Kritische Daten fehlen: {criticalItem?.field} + + ) + } + + return ( + + + Datenqualität + + {hasCritical && ( + + Kritische Daten fehlen: {criticalItem?.field} + + )} + + + + + + {pct}% + + + {missingData.length > 0 && ( + + {missingData.length} fehlende{missingData.length > 1 ? '' : 's'} Feld{missingData.length > 1 ? 'er' : ''} + {criticalItem ? ` · kritisch: ${criticalItem.field}` : ''} + + )} + + ) +} diff --git a/src/components/match-card/MatchReasonList.tsx b/src/components/match-card/MatchReasonList.tsx new file mode 100644 index 0000000..7c6171b --- /dev/null +++ b/src/components/match-card/MatchReasonList.tsx @@ -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 ( + + + Warum dieses Match + + + {shown.map((r, i) => ( + + + + + {r.label} + + {r.explanation && ( + + {r.explanation} + + )} + + + ))} + + + ) +} diff --git a/src/components/match-card/MatchScoreDisplay.tsx b/src/components/match-card/MatchScoreDisplay.tsx new file mode 100644 index 0000000..a94acf9 --- /dev/null +++ b/src/components/match-card/MatchScoreDisplay.tsx @@ -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 = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' } + +export function MatchScoreDisplay({ score, size = 'md' }: Props) { + return ( + + + {score} + + /100 + + ) +} diff --git a/src/components/match-card/TradeoffList.tsx b/src/components/match-card/TradeoffList.tsx new file mode 100644 index 0000000..6dae321 --- /dev/null +++ b/src/components/match-card/TradeoffList.tsx @@ -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 ( + + + Abwägungen + + + {shown.map((t, i) => ( + + + + {compact ? ( + + {t.concern} + + ) : ( + <> + + {t.criterion} + + + {t.concern} + + {t.mitigation && ( + + → {t.mitigation} + + )} + + )} + + + ))} + + + ) +} diff --git a/src/components/match-card/index.ts b/src/components/match-card/index.ts new file mode 100644 index 0000000..cc6f354 --- /dev/null +++ b/src/components/match-card/index.ts @@ -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' diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index e8c2714..6b590e9 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -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 ( - - {/* FUTURE_AVAILABILITY disclaimer — always shown, non-dismissable */} - {isFuture && ( - - {signal?.disclaimer ?? 'Probabilistisches Signal – kein bestätigtes Objekt'} - - )} - - {/* Header */} - - - - {title} - - - - {matchScore} - - /100 - - - - {/* Key facts */} - - - - {city} - - {areaSqm !== undefined && ( - - - {areaSqm} m² - - )} - {rent !== undefined && ( - - - CHF {rent}/m² - - )} - {available && ( - - - {available} - - )} - {isFuture && signal?.timeHorizonMonths && ( - - - - ~{signal.timeHorizonMonths} Monate · {Math.round(signal.probability * 100)}% Wahrscheinlichkeit - - - )} - - - - - {/* Positive factors */} - {match.positiveFactors.length > 0 && ( - - - Positive Faktoren - - - {match.positiveFactors.slice(0, 3).map((f, i) => ( - - ))} - - - )} - - {/* Tradeoffs */} - {(match.tradeoffs?.length ?? 0) > 0 && ( - - Abwägungen - {match.tradeoffs.slice(0, 2).map((t, i) => ( - - {t.criterion}: {t.concern} - - ))} - - )} - - {/* Confidence + data quality */} - - - - - {/* Actions */} - - - {!isFuture && ( - - )} - - - - ) + const vm = buildMatchCardViewModel(result, actions) + return } diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts new file mode 100644 index 0000000..84d9b31 --- /dev/null +++ b/src/features/matching/matchCardAdapter.ts @@ -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, + } +}