From c6055f06119d6550b1dacca0a27bb2d77f0f0c80 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 01:13:59 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20CompareTableBody=20from?= =?UTF-8?q?=20Compare.tsx=20(441=20=E2=86=92=20147=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 18 inline table row definitions into CompareTableBody component Co-Authored-By: Claude Sonnet 4.6 --- src/components/compare/CompareTableBody.tsx | 340 ++++++++++++++++++++ src/components/compare/index.ts | 1 + src/pages/demand/Compare.tsx | 312 +----------------- 3 files changed, 350 insertions(+), 303 deletions(-) create mode 100644 src/components/compare/CompareTableBody.tsx 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} - )} - - ) - }))} - -