diff --git a/src/components/compare/CompareTableBody.tsx b/src/components/compare/CompareTableBody.tsx
new file mode 100644
index 0000000..7eee20d
--- /dev/null
+++ b/src/components/compare/CompareTableBody.tsx
@@ -0,0 +1,340 @@
+import type { ReactNode } from 'react'
+import {
+ Box,
+ Chip,
+ TableBody,
+ TableCell,
+ TableRow,
+ Typography,
+} from '@mui/material'
+import { AlertOctagon, AlertTriangle, CheckCircle2, Trophy, XCircle, Zap } from 'lucide-react'
+import {
+ HARD_CRITERIA,
+ SCORE_COLOR,
+ TYPE_META,
+ RISK_LEVEL_ORDER,
+ getProp,
+ getSig,
+ LABEL_SX,
+ DATA_SX,
+ scoreBar,
+} from './compareUtils'
+import { CompareCell, MissingDataCell } from './index'
+import type { UnifiedMatchResult } from '../../domain/unifiedResult'
+
+// ── Local helper ──────────────────────────────────────────────────────────────
+
+function row(label: string, cells: ReactNode[]) {
+ return (
+
+ {label}
+ {cells.map((cell, i) => (
+ {cell}
+ ))}
+
+ )
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface CompareTableBodyProps {
+ compareItems: UnifiedMatchResult[]
+ bestScoreIdx: number
+ worstConfIdx: number
+ worstDQIdx: number
+ missingCriticalCounts: number[]
+ maxMissingCritical: number
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function CompareTableBody({
+ compareItems,
+ bestScoreIdx,
+ worstConfIdx,
+ worstDQIdx,
+ missingCriticalCounts,
+ maxMissingCritical,
+}: CompareTableBodyProps) {
+ return (
+
+
+ {/* 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 (
+ : 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}
+ )}
+
+ )
+ }))}
+
+
+ )
+}
diff --git a/src/components/compare/index.ts b/src/components/compare/index.ts
index 5817254..0001107 100644
--- a/src/components/compare/index.ts
+++ b/src/components/compare/index.ts
@@ -3,3 +3,4 @@ export { CompareColumnHeader } from './CompareColumnHeader'
export { CompareCell, MissingDataCell } from './CompareCell'
export { AICompareSummary } from './AICompareSummary'
export { CompareCriteriaCard } from './CompareCriteriaCard'
+export { CompareTableBody } from './CompareTableBody'
diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx
index d42a734..2c0fc2d 100644
--- a/src/pages/demand/Compare.tsx
+++ b/src/pages/demand/Compare.tsx
@@ -1,4 +1,3 @@
-import type { ReactNode } from 'react'
import {
Alert,
Box,
@@ -7,50 +6,27 @@ import {
Chip,
Stack,
Table,
- TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material'
-import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useCompareStore } from '../../stores/compareStore'
import {
CompareEmptyState,
CompareColumnHeader,
- CompareCell,
- MissingDataCell,
AICompareSummary,
CompareCriteriaCard,
+ CompareTableBody,
} from '../../components/compare'
import { AddToPipelineDialog } from '../../components/shortlist'
import {
- HARD_CRITERIA,
- SCORE_COLOR,
- TYPE_META,
- RISK_LEVEL_ORDER,
- getProp,
- getSig,
LABEL_SX,
DATA_SX,
- scoreBar,
} from '../../components/compare/compareUtils'
import { useCompareData } from '../../hooks/useCompareData'
-// ── Module-level helpers ──────────────────────────────────────────────────────
-
-function row(label: string, cells: ReactNode[]) {
- return (
-
- {label}
- {cells.map((cell, i) => (
- {cell}
- ))}
-
- )
-}
-
// ── Main component ────────────────────────────────────────────────────────────
export default function Compare() {
@@ -137,285 +113,15 @@ export default function Compare() {
-
+
- {/* 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 (
- : 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}
- )}
-
- )
- }))}
-
-