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, RISK_LEVEL_ORDER, getProp, getSig, LABEL_SX, DATA_SX, scoreBar, } from './compareUtils' import { CompareCell, MissingDataCell } from './index' import { DS_ACCENT, DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT, RESULT_TYPE_META } from '../../lib/ds' import { matchScoreHex } from '../../lib/utils' import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils' import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants' 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 = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] } 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 — full cost breakdown incl. amortised fit-out */} {row('9. Kosten / Budget', compareItems.map(item => { const prop = getProp(item) if (!prop) return const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord const monthlyRent = prop.totalRentMonthly ?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12) // ancillaryCosts stored as CHF/m²/Monat const monthlyNebenkosten = prop.ancillaryCosts != null ? Math.round(prop.ancillaryCosts * prop.areaSqm) : null const fitOut = prop.hardFacts?.fitOut const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0 // Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig) const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord, }) const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12) const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null const totalMonthly = monthlyRent + (monthlyNebenkosten ?? 0) + fitOutMonthly return ( CHF {prop.rentPricePerSqm}/m²/Jahr {monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete) {monthlyNebenkosten != null && ( + {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK) )} {fitOutMonthlyLabel && ( + {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%) )} = {totalMonthly.toLocaleString('de-CH')} CHF/Monat {fitOutLabel && ( Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''} )} ) }))} {/* 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: DS_ACCENT.success.bg, color: DS_TEXT.successDark, '& .MuiChip-icon': { color: DS_ACCENT.success.main } }} /> ))} ) }))} {/* 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} )} ) }))} ) }