diff --git a/src/components/compare/AICompareSummary.tsx b/src/components/compare/AICompareSummary.tsx
new file mode 100644
index 0000000..8ffcf8b
--- /dev/null
+++ b/src/components/compare/AICompareSummary.tsx
@@ -0,0 +1,133 @@
+import { useState } from 'react'
+import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
+import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight } from 'lucide-react'
+import type { ComparisonSummary } from '../../services/aiService'
+
+interface Props {
+ summary?: ComparisonSummary
+ isLoading: boolean
+}
+
+export function AICompareSummary({ summary, isLoading }: Props) {
+ const [open, setOpen] = useState(true)
+
+ return (
+
+ setOpen(v => !v)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ px: 2,
+ py: 1.25,
+ bgcolor: '#f8fafc',
+ cursor: 'pointer',
+ '&:hover': { bgcolor: '#f1f5f9' },
+ }}
+ >
+
+ AI Vergleichs-Zusammenfassung
+
+
+ {open ? : }
+
+
+
+
+ {isLoading && (
+
+
+ Analyse wird erstellt…
+
+ )}
+
+ {!isLoading && summary && (
+
+ {/* Strongest option */}
+
+
+
+ Stärkstes Match
+ {summary.strongestOption.label}
+ {summary.strongestOption.reason}
+
+
+
+ {/* Best value */}
+ {summary.bestValue && (
+
+
+
+ Bestes Preis-Leistungs-Verhältnis
+ {summary.bestValue.label}
+ {summary.bestValue.reason}
+
+
+ )}
+
+ {/* Highest confidence */}
+
+
+
+ Höchste Datenkonfidenz
+
+ {summary.highestConfidence.label}
+
+ ({Math.round(summary.highestConfidence.confidenceLevel * 100)}%)
+
+
+
+
+
+ {/* Tradeoffs */}
+ {summary.biggestTradeoffs.length > 0 && (
+
+
+
+ Wichtigste Abwägungen
+ {summary.biggestTradeoffs.map((t, i) => (
+ · {t}
+ ))}
+
+
+ )}
+
+ {/* Missing data */}
+ {summary.missingDataWarnings.length > 0 && (
+
+
+
+ Fehlende Informationen
+ {summary.missingDataWarnings.map((w, i) => (
+ · {w}
+ ))}
+
+
+ )}
+
+ {/* Next step */}
+
+
+
+ Empfohlener nächster Schritt
+ {summary.recommendedNextStep}
+
+
+
+ )}
+
+ {!isLoading && !summary && (
+
+ Mindestens 2 Ergebnisse auswählen, um die Zusammenfassung zu generieren.
+
+ )}
+
+
+ Diese Zusammenfassung basiert ausschliesslich auf den vorliegenden Daten und trifft keine endgültige Entscheidung.
+
+
+
+
+ )
+}
diff --git a/src/components/compare/CompareCell.tsx b/src/components/compare/CompareCell.tsx
new file mode 100644
index 0000000..6272b98
--- /dev/null
+++ b/src/components/compare/CompareCell.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from 'react'
+import { Box, Tooltip, Typography } from '@mui/material'
+import { Info } from 'lucide-react'
+
+export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none'
+
+interface Props {
+ highlight?: CellHighlight
+ icon?: ReactNode
+ iconTooltip?: string
+ children: ReactNode
+}
+
+const HIGHLIGHT_SX: Record = {
+ best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' },
+ worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' },
+ critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' },
+ future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' },
+ none: {},
+}
+
+export function CompareCell({ highlight = 'none', icon, iconTooltip, children }: Props) {
+ return (
+
+ {icon && (
+ iconTooltip
+ ? {icon}
+ : {icon}
+ )}
+ {children}
+
+ )
+}
+
+export function MissingDataCell({ reason }: { reason?: string }) {
+ return (
+
+
+
+
+
+
+
+ Nicht verfügbar
+
+
+ )
+}
diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx
new file mode 100644
index 0000000..92685f5
--- /dev/null
+++ b/src/components/compare/CompareColumnHeader.tsx
@@ -0,0 +1,85 @@
+import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material'
+import { X, AlertTriangle } from 'lucide-react'
+import type { UnifiedMatchResult } from '../../domain/unifiedResult'
+
+const TYPE_META: Record = {
+ VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
+ EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
+ FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
+}
+
+const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
+
+interface Props {
+ item: UnifiedMatchResult
+ onRemove: () => void
+}
+
+export function CompareColumnHeader({ item, onRemove }: Props) {
+ const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
+ const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
+ const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
+
+ const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–'
+ const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–'
+ const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null)
+ const confidence = Math.round(item.match.confidenceLevel * 100)
+ const source = prop?.sourceLabel ?? sig?.source?.type ?? '–'
+
+ return (
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ {item.matchScore}
+
+ /100
+
+
+
+
+ : undefined}
+ sx={{
+ fontSize: 10,
+ bgcolor: confidence < 60 ? '#fef3c7' : '#f0fdf4',
+ color: confidence < 60 ? '#92400e' : '#166534',
+ }}
+ />
+
+ {source && source !== '–' && (
+
+ )}
+ {availability && (
+
+ )}
+
+
+ {item.resultType === 'FUTURE_AVAILABILITY' && sig && (
+
+
+ Probabilistisches Signal
+
+
+ {Math.round(sig.probability * 100)}% Wahrscheinlichkeit
+
+
+ )}
+
+ )
+}
diff --git a/src/components/compare/CompareEmptyState.tsx b/src/components/compare/CompareEmptyState.tsx
new file mode 100644
index 0000000..de4f42e
--- /dev/null
+++ b/src/components/compare/CompareEmptyState.tsx
@@ -0,0 +1,24 @@
+import { Box, Button, Typography } from '@mui/material'
+import { Columns2 } from 'lucide-react'
+import { useNavigate } from 'react-router'
+
+export function CompareEmptyState() {
+ const navigate = useNavigate()
+ return (
+
+
+
+
+ Keine Ergebnisse zum Vergleich
+
+
+ Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
+
+
+
+
+ )
+}
diff --git a/src/components/compare/index.ts b/src/components/compare/index.ts
new file mode 100644
index 0000000..bf03c37
--- /dev/null
+++ b/src/components/compare/index.ts
@@ -0,0 +1,4 @@
+export { CompareEmptyState } from './CompareEmptyState'
+export { CompareColumnHeader } from './CompareColumnHeader'
+export { CompareCell, MissingDataCell } from './CompareCell'
+export { AICompareSummary } from './AICompareSummary'
diff --git a/src/components/layout/CompareTray.tsx b/src/components/layout/CompareTray.tsx
index ebd899b..b9b890a 100644
--- a/src/components/layout/CompareTray.tsx
+++ b/src/components/layout/CompareTray.tsx
@@ -1,17 +1,31 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router'
-import { Box, Button, Chip, Typography } from '@mui/material'
+import { Box, Button, IconButton, Typography } from '@mui/material'
+import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
import { useLayoutStore } from '../../stores/layoutStore'
+const TYPE_DOT: Record = {
+ VERIFIED_PORTFOLIO: '#1e3a5f',
+ EXTERNAL_MARKET: '#d97706',
+ FUTURE_AVAILABILITY: '#7c3aed',
+}
+
export function CompareTray() {
- const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
+ const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
const { setCompareTrayVisible } = useLayoutStore()
const navigate = useNavigate()
useEffect(() => {
- setCompareTrayVisible(compareTray.length > 0)
- }, [compareTray.length, setCompareTrayVisible])
+ setCompareTrayVisible(compareItems.length > 0)
+ }, [compareItems.length, setCompareTrayVisible])
+
+ const getTitle = (item: (typeof compareItems)[number]) => {
+ if (item.resultType === 'FUTURE_AVAILABILITY') {
+ return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal'
+ }
+ return (item as any).property?.title ?? `Score ${item.matchScore}`
+ }
return (
0 ? 'translateY(0)' : 'translateY(100%)',
+ transform: compareItems.length > 0 ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 0.25s ease',
}}
>
- Vergleich ({compareTray.length}/3)
+ Vergleich ({compareItems.length}/4)
- {compareTray.map((id, i) => (
- removeFromCompare(id)}
+ {compareItems.map((item) => (
+
+ >
+
+
+ {getTitle(item)}
+
+
+ {item.matchScore}
+
+ removeFromCompare(item.matchId)}
+ sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#fff' } }}
+ >
+
+
+
))}
diff --git a/src/components/match-center/MatchBriefingPanel.tsx b/src/components/match-center/MatchBriefingPanel.tsx
index f0cd39e..5e0af90 100644
--- a/src/components/match-center/MatchBriefingPanel.tsx
+++ b/src/components/match-center/MatchBriefingPanel.tsx
@@ -66,7 +66,7 @@ export function MatchBriefingPanel() {
label: 'Vergleichen',
actionType: 'ADD_COMPARE',
variant: 'secondary',
- onClick: () => { addToCompare(property.id); navigate('/demand/compare') },
+ onClick: () => { addToCompare(result); navigate('/demand/compare') },
},
{
id: 'details',
diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx
index 53e73a8..25e4c41 100644
--- a/src/components/results/UnifiedResultCard.tsx
+++ b/src/components/results/UnifiedResultCard.tsx
@@ -1,21 +1,18 @@
import { useNavigate } from 'react-router'
import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
+import { useCompareStore } from '../../stores/compareStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
interface Props {
result: UnifiedMatchResult
- isInCompare: boolean
- onCompare: (propertyId: string) => void
}
-export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
+export function UnifiedResultCard({ result }: Props) {
const navigate = useNavigate()
- const isFuture = result.resultType === 'FUTURE_AVAILABILITY'
- const compareId = !isFuture
- ? (result as { property: { id: string } }).property.id
- : ''
+ const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
+ const inCompare = isInCompare(result.matchId)
const actions: MatchCardAction[] = [
{
@@ -26,17 +23,17 @@ export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
disabled: true,
onClick: () => {},
},
- ...(!isFuture
- ? [
- {
- id: 'compare',
- label: 'Vergleichen',
- actionType: 'ADD_COMPARE' as const,
- variant: (isInCompare ? 'primary' : 'secondary') as 'primary' | 'secondary',
- onClick: () => onCompare(compareId),
- },
- ]
- : []),
+ {
+ id: 'compare',
+ label: inCompare ? 'Im Vergleich' : 'Vergleichen',
+ actionType: 'ADD_COMPARE',
+ variant: inCompare ? 'primary' : 'secondary',
+ disabled: !inCompare && isFull(),
+ onClick: () => {
+ if (inCompare) removeFromCompare(result.matchId)
+ else addToCompare(result)
+ },
+ },
{
id: 'details',
label: 'Details',
diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx
index ab8adbc..0aaad78 100644
--- a/src/components/results/UnifiedResultFeed.tsx
+++ b/src/components/results/UnifiedResultFeed.tsx
@@ -3,27 +3,14 @@ import { UnifiedResultCard } from './UnifiedResultCard'
interface Props {
results: UnifiedMatchResult[]
- isInCompare: (id: string) => boolean
- onCompare: (propertyId: string) => void
}
-export function UnifiedResultFeed({ results, isInCompare, onCompare }: Props) {
+export function UnifiedResultFeed({ results }: Props) {
return (
<>
- {results.map(result => {
- const compareId =
- result.resultType !== 'FUTURE_AVAILABILITY'
- ? (result as { property: { id: string } }).property.id
- : ''
- return (
-
- )
- })}
+ {results.map(result => (
+
+ ))}
>
)
}
diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx
index a0dfabd..724234a 100644
--- a/src/pages/demand/Compare.tsx
+++ b/src/pages/demand/Compare.tsx
@@ -1,310 +1,483 @@
import type { ReactNode } from 'react'
import {
+ Alert,
Box,
Button,
Card,
Chip,
- Typography,
LinearProgress,
+ Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
- CircularProgress,
- Stack,
+ Typography,
} from '@mui/material'
-import { X } from 'lucide-react'
-import { useQuery } from '@tanstack/react-query'
+import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react'
import { useNavigate } from 'react-router'
-import { propertyService } from '../../services/propertyService'
+import { useQuery } from '@tanstack/react-query'
import { useCompareStore } from '../../stores/compareStore'
-import { EmptyState } from '../../components/ui'
-import { ResultType, RiskLevel } from '../../domain/enums'
-import type { Property } from '../../domain/property'
+import { aiService } from '../../services/aiService'
+import {
+ CompareEmptyState,
+ CompareColumnHeader,
+ CompareCell,
+ MissingDataCell,
+ AICompareSummary,
+} from '../../components/compare'
+import type { UnifiedMatchResult } from '../../domain/unifiedResult'
+import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
-function getResultTypeLabel(type: ResultType): string {
- switch (type) {
- case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
- case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
- case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
- }
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
+const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
+
+function getProp(item: UnifiedMatchResult) {
+ return item.resultType !== 'FUTURE_AVAILABILITY'
+ ? (item as VerifiedPortfolioResult | ExternalMarketResult).property
+ : null
}
-function getResultTypeColor(type: ResultType): string {
- switch (type) {
- case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
- case ResultType.EXTERNAL_MARKET: return '#d97706'
- case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
- }
+function getSig(item: UnifiedMatchResult) {
+ return item.resultType === 'FUTURE_AVAILABILITY'
+ ? (item as FutureAvailabilityResult).signal
+ : null
}
-function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
- if (!risk) return 'default'
- if (risk === RiskLevel.LOW) return 'success'
- if (risk === RiskLevel.MEDIUM) return 'warning'
- return 'error'
+const TYPE_META: Record = {
+ VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
+ EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
+ FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
-function getRiskLabel(risk?: RiskLevel): string {
- if (!risk) return '–'
- switch (risk) {
- case RiskLevel.LOW: return 'Niedrig'
- case RiskLevel.MEDIUM: return 'Mittel'
- case RiskLevel.HIGH: return 'Hoch'
- case RiskLevel.CRITICAL: return 'Kritisch'
- }
+const RISK_LEVEL_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
+
+// ── Row label cell ────────────────────────────────────────────────────────────
+
+const LABEL_SX = {
+ position: 'sticky' as const,
+ left: 0,
+ bgcolor: 'white',
+ zIndex: 1,
+ width: 200,
+ minWidth: 200,
+ color: '#64748b',
+ fontSize: 13,
+ fontWeight: 600,
+ borderRight: '1px solid #e2e8f0',
+ verticalAlign: 'top',
+ py: 1.5,
}
-interface CompareRow {
- label: string
- getValue: (p: Property) => string | number | null
- format?: (v: string | number | null, p: Property) => ReactNode
- isHigherBetter?: boolean
- isLowerBetter?: boolean
+const DATA_SX = {
+ borderLeft: '1px solid #f1f5f9',
+ minWidth: 220,
+ verticalAlign: 'top',
+ py: 1.5,
}
-function NumericCell({ value, isBest }: { value: ReactNode; isBest: boolean }) {
- return (
-
- {value}
-
- )
-}
+// ── Main component ────────────────────────────────────────────────────────────
export default function Compare() {
const navigate = useNavigate()
- const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
+ const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
- const { data: propResp, isLoading } = useQuery({
- queryKey: ['properties'],
- queryFn: () => propertyService.getAll(),
+ const { data: aiSummary, isLoading: aiLoading } = useQuery({
+ queryKey: ['ai-compare', compareItems.map(i => i.matchId)],
+ queryFn: () => aiService.summarizeComparison(compareItems),
+ enabled: compareItems.length >= 2,
+ select: r => r.data,
+ staleTime: Infinity,
})
- const properties = propResp?.data ?? []
- const compareProperties = properties.filter(p => compareTray.includes(p.id))
- // Keep ordering same as tray
- const orderedProperties = compareTray
- .map(id => compareProperties.find(p => p.id === id))
- .filter((p): p is Property => p !== undefined)
-
- if (isLoading) {
- return (
-
-
-
- )
- }
-
- if (compareTray.length === 0) {
+ if (compareItems.length === 0) {
return (
Vergleich
-
- navigate('/demand/results') }}
- />
-
+
)
}
- const rows: CompareRow[] = [
- {
- label: 'Fläche (m²)',
- getValue: p => p.areaSqm,
- isHigherBetter: false,
- },
- {
- label: 'Miete/m² (CHF)',
- getValue: p => p.rentPricePerSqm,
- isLowerBetter: true,
- },
- {
- label: 'Gesamtmiete/Monat (CHF)',
- getValue: p => p.totalRentMonthly ?? null,
- format: (v) => v != null ? `${Number(v).toLocaleString('de-CH')} CHF` : –,
- isLowerBetter: true,
- },
- {
- label: 'Verfügbarkeit',
- getValue: p => p.availabilityDate,
- format: (v) => v ?? –,
- },
- {
- label: 'Standort',
- getValue: p => `${p.location.city}${p.location.district ? ', ' + p.location.district : ''}`,
- },
- {
- label: 'Datenqualität',
- getValue: p => p.dataQuality.score,
- format: (_v, p) => (
-
-
- = 0.8 ? 'success' : p.dataQuality.score >= 0.6 ? 'warning' : 'error' }}
- />
-
- {Math.round(p.dataQuality.score * 100)}%
+ // ── Highlight indices ──────────────────────────────────────────────────────
+
+ const bestScoreIdx = compareItems.reduce(
+ (best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0
+ )
+ const worstConfIdx = compareItems.reduce(
+ (worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0
+ )
+ const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1)
+ const worstDQIdx = dqScores.indexOf(Math.min(...dqScores))
+
+ const missingCriticalCounts = compareItems.map(
+ item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0
+ )
+ const maxMissingCritical = Math.max(...missingCriticalCounts)
+
+ // ── Shared render helpers ─────────────────────────────────────────────────
+
+ function row(label: string, cells: ReactNode[]) {
+ return (
+
+ {label}
+ {cells.map((cell, i) => (
+ {cell}
+ ))}
+
+ )
+ }
+
+ function scoreBar(value: number, label?: string) {
+ const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
+ return (
+
+
+
- ),
- isHigherBetter: true,
- },
- {
- label: 'Konfidenz',
- getValue: p => p.confidenceScore,
- format: (v) => v != null ? `${Math.round(Number(v) * 100)}%` : –,
- isHigherBetter: true,
- },
- {
- label: 'Risiko',
- getValue: p => p.riskLevel ?? null,
- format: (_v, p) => (
-
- ),
- },
- {
- label: 'Prestige',
- getValue: p => p.softFactors?.prestige ?? null,
- format: (v) => v != null ? String(v) : –,
- isHigherBetter: true,
- },
- {
- label: 'Erreichbarkeit',
- getValue: p => p.softFactors?.accessibility ?? null,
- format: (v) => v != null ? String(v) : –,
- isHigherBetter: true,
- },
- {
- label: 'ÖV-Minuten',
- getValue: p => p.softFactors?.publicTransportMinutes ?? null,
- format: (v) => v != null ? `${v} Min.` : –,
- isLowerBetter: true,
- },
- {
- label: 'Fehlende Pflichtfelder',
- getValue: p => p.dataQuality.missingCriticalFields.length,
- format: (v) => v != null ? String(v) : –,
- isLowerBetter: true,
- },
- ]
+ {label ?? `${Math.round(value * 100)}%`}
+
+ )
+ }
return (
- {/* Page Header */}
+ {/* Page header */}
Vergleich
-
+
-
+ {/* Mobile notice */}
+
+
+ Die Vergleichsansicht ist für Desktop optimiert. Für beste Erfahrung auf einem grösseren Bildschirm öffnen.
+
+
+
+ {/* Desktop table */}
+
+
+ {/* AI Summary */}
+ = 2} />
+
-
+
+
+ {/* Column headers */}
-
+
Kriterium
- {orderedProperties.map(p => (
-
-
-
- {p.title}
-
-
-
-
+ {compareItems.map(item => (
+
+ removeFromCompare(item.matchId)} />
))}
- {rows.map(row => {
- const values = orderedProperties.map(p => row.getValue(p))
- const numericValues = values
- .map((v, i) => ({ v, i }))
- .filter(x => x.v != null && typeof x.v === 'number') as { v: number; i: number }[]
- let bestIdx = -1
- if (numericValues.length > 1) {
- if (row.isHigherBetter) {
- bestIdx = numericValues.reduce((best, cur) => cur.v > best.v ? cur : best).i
- } else if (row.isLowerBetter) {
- bestIdx = numericValues.reduce((best, cur) => cur.v < best.v ? cur : best).i
- }
- }
+ {/* 1. Result Type */}
+ {row('1. Result-Typ', compareItems.map(item => {
+ const m = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
+ return
+ }))}
+ {/* 2. Source / Provenance */}
+ {row('2. Quelle / Provenienz', compareItems.map(item => {
+ const prop = getProp(item)
+ const sig = getSig(item)
+ const label = prop?.sourceLabel ?? sig?.source?.type ?? null
+ return label
+ ? {label}
+ :
+ }))}
+
+ {/* 3. Match Score */}
+ {row('3. Match Score', compareItems.map((item, idx) => (
+ : undefined}
+ iconTooltip="Höchster Match Score"
+ >
+
+
+ {item.matchScore}
+
+ /100
+
+
+ )))}
+
+ {/* 4. Confidence Score */}
+ {row('4. Konfidenz', compareItems.map((item, idx) => (
+ : undefined}
+ iconTooltip="Niedrigste Konfidenz"
+ >
+ {scoreBar(item.match.confidenceLevel)}
+
+ )))}
+
+ {/* 5. Data Quality Score */}
+ {row('5. Datenqualität', compareItems.map((item, idx) => {
+ const prop = getProp(item)
+ const dq = prop?.dataQuality.score ?? null
+ if (dq === null) return
return (
-
-
- {row.label}
-
- {orderedProperties.map((p, idx) => {
- const raw = row.getValue(p)
- const displayValue = row.format
- ? row.format(raw, p)
- : raw != null
- ? String(raw)
- : –
-
- const isBest = bestIdx === idx
- return (
-
- )
- })}
-
+ : undefined}
+ iconTooltip="Niedrigste Datenqualität"
+ >
+ {scoreBar(dq)}
+
)
- })}
+ }))}
+
+ {/* 6. Asset Type */}
+ {row('6. Nutzungstyp', compareItems.map(item => {
+ const prop = getProp(item)
+ const label = prop?.assetType ?? null
+ return label
+ ?
+ :
+ }))}
+
+ {/* 7. Location */}
+ {row('7. Standort', compareItems.map(item => {
+ const prop = getProp(item)
+ const sig = getSig(item)
+ const city = prop?.location?.city ?? sig?.locationHint ?? null
+ const district = prop?.location?.district
+ return city
+ ? {city}{district ? `, ${district}` : ''}
+ :
+ }))}
+
+ {/* 8. Area */}
+ {row('8. Fläche', compareItems.map(item => {
+ const prop = getProp(item)
+ const sig = getSig(item)
+ const area = prop?.areaSqm ?? sig?.areaSqmEstimate ?? null
+ return area !== null
+ ? {area.toLocaleString('de-CH')} m²{sig ? ' (Schätzung)' : ''}
+ :
+ }))}
+
+ {/* 9. Rent / Budget Fit */}
+ {row('9. Miete / Budget', compareItems.map(item => {
+ const prop = getProp(item)
+ if (!prop) return
+ return (
+
+
+ CHF {prop.rentPricePerSqm}/m²
+
+ {prop.totalRentMonthly && (
+
+ {prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
+
+ )}
+
+ )
+ }))}
+
+ {/* 10. Availability / Time Horizon */}
+ {row('10. Verfügbarkeit', compareItems.map(item => {
+ const prop = getProp(item)
+ const sig = getSig(item)
+ if (prop) return {prop.availabilityDate}
+ if (sig) return (
+ } iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit">
+ ~{sig.timeHorizonMonths} Monate
+
+ {Math.round(sig.probability * 100)}% Wahrscheinlichkeit
+
+
+ )
+ return
+ }))}
+
+ {/* 11. Hard Criteria Fit */}
+ {row('11. Hardkriterien', compareItems.map(item => {
+ const hardMatches = item.match.positiveFactors.filter(f => HARD_CRITERIA.has(f.criterion))
+ const total = 4
+ const count = hardMatches.length
+ const color = count >= 3 ? '#1a7a4a' : count >= 2 ? '#d97706' : '#c0392b'
+ return (
+
+
+ {count}/{total} erfüllt
+
+
+ {hardMatches.map(f => (
+ }
+ sx={{ fontSize: 10, bgcolor: '#f0fdf4', color: '#166534', '& .MuiChip-icon': { color: '#1a7a4a' } }} />
+ ))}
+
+
+ )
+ }))}
+
+ {/* 12. Top Soft Factors */}
+ {row('12. Soft Factors', compareItems.map(item => {
+ const softFactors = item.match.positiveFactors
+ .filter(f => !HARD_CRITERIA.has(f.criterion))
+ .slice(0, 3)
+ if (softFactors.length === 0) return
+ return (
+
+ {softFactors.map(f => (
+
+ ))}
+
+ )
+ }))}
+
+ {/* 13. Main Strengths */}
+ {row('13. Stärken', compareItems.map(item => {
+ const top = item.match.positiveFactors.slice(0, 2)
+ if (top.length === 0) return
+ return (
+
+ {top.map((f, i) => (
+
+
+ {f.explanation}
+
+ ))}
+
+ )
+ }))}
+
+ {/* 14. Main Tradeoffs */}
+ {row('14. Abwägungen', compareItems.map(item => {
+ const tradeoffs = item.match.tradeoffs?.slice(0, 2) ?? []
+ if (tradeoffs.length === 0) return (
+ Keine signifikanten Abwägungen
+ )
+ return (
+
+ {tradeoffs.map((t, i) => (
+
+
+ {t.criterion}: {t.concern}
+
+ ))}
+
+ )
+ }))}
+
+ {/* 15. Main Risks */}
+ {row('15. Risiken', compareItems.map(item => {
+ const risks = [...(item.match.risks ?? [])].sort(
+ (a, b) => (RISK_LEVEL_ORDER[a.level] ?? 4) - (RISK_LEVEL_ORDER[b.level] ?? 4)
+ ).slice(0, 2)
+ if (risks.length === 0) return (
+ Keine identifizierten Risiken
+ )
+ return (
+
+ {risks.map((r, i) => (
+
+
+ {r.description}
+
+ ))}
+
+ )
+ }))}
+
+ {/* 16. Missing Data */}
+ {row('16. Fehlende Daten', compareItems.map((item, idx) => {
+ const total = item.match.missingData?.length ?? 0
+ const critical = missingCriticalCounts[idx]
+ if (total === 0) return (
+
+
+ Vollständig
+
+ )
+ return (
+ 0 && critical === maxMissingCritical ? 'critical' : critical > 0 ? 'worst' : 'none'}
+ icon={critical > 0 ? : }
+ iconTooltip={critical > 0 ? 'Kritische Pflichtfelder fehlen' : 'Optionale Felder fehlen'}
+ >
+ {total} fehlend
+ {critical > 0 && (
+ {critical} kritisch
+ )}
+
+ )
+ }))}
+
+ {/* 17. Future Availability Context */}
+ {row('17. Zukunftskontext', compareItems.map(item => {
+ const sig = getSig(item)
+ if (!sig) return (
+ Nicht anwendbar
+ )
+ return (
+ } iconTooltip="Probabilistisches Zukunftssignal">
+
+
+ {Math.round(sig.probability * 100)}% Wahrscheinlichkeit
+
+
+ Sensitivität: {sig.sensitivityLevel}
+
+
+ {sig.disclaimer}
+
+
+
+ )
+ }))}
+
+ {/* 18. Recommended Next Action */}
+ {row('18. Nächste Aktion', compareItems.map(item => {
+ const action = item.match.nextBestActions?.[0]
+ if (!action) return
+ return (
+
+ {action.label}
+ {action.description && (
+ {action.description}
+ )}
+
+ )
+ }))}
+
{/* Add more prompt */}
- {orderedProperties.length < 3 && (
+ {compareItems.length < 4 && (
- Weiteres Objekt hinzufügen
+ Weiteres Ergebnis hinzufügen
- Bis zu {3 - orderedProperties.length} weitere{orderedProperties.length < 2 ? 's' : ''} Objekt{orderedProperties.length < 2 ? '' : 'e'} möglich
+ Bis zu {4 - compareItems.length} weitere{4 - compareItems.length === 1 ? 's' : ''} Ergebnis{4 - compareItems.length === 1 ? '' : 'se'} möglich