refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements
- Taxonomy: merge VERIFIED_PORTFOLIO + EXTERNAL_MARKET display → 'Plattform' (dark blue) across all surfaces - HeatBadge: new flame indicator for hot properties (grid, list, pipeline views) - FutureAvailabilityContextPanel: richer detail page with AI summary, strategic assessment, sources - Refactor Pipeline.tsx (630→152 lines) → pipeline/PipelineCard, PipelineColumn, PipelineDetailPanel, pipelineConstants, pipelineUtils - Refactor IntelligenceMatchCard.tsx (483→179 lines) → FutureAvailabilityCard extracted - Refactor MatchDetail.tsx (559→464 lines) → useMatchDetailData hook, MatchDetailPropertyDetails - Refactor Compare.tsx (638→485 lines) → compareUtils, CompareCriteriaCard extracted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
-182
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,81 +27,33 @@ import {
|
||||
CompareCell,
|
||||
MissingDataCell,
|
||||
AICompareSummary,
|
||||
CompareCriteriaCard,
|
||||
} from '../../components/compare'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
||||
import {
|
||||
HARD_CRITERIA,
|
||||
SCORE_COLOR,
|
||||
TYPE_META,
|
||||
RISK_LEVEL_ORDER,
|
||||
CRITERION_ALIASES,
|
||||
getProp,
|
||||
getSig,
|
||||
LABEL_SX,
|
||||
DATA_SX,
|
||||
scoreBar,
|
||||
} from '../../components/compare/compareUtils'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── Module-level 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 getSig(item: UnifiedMatchResult) {
|
||||
return item.resultType === 'FUTURE_AVAILABILITY'
|
||||
? (item as FutureAvailabilityResult).signal
|
||||
: null
|
||||
}
|
||||
|
||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
|
||||
const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
||||
area: ['area', 'Fläche', 'fläche'],
|
||||
location: ['location', 'Standort', 'standort'],
|
||||
budget: ['budget', 'Budget', 'Mietpreis', 'mietpreis'],
|
||||
timing: ['timing', 'Verfügbarkeit', 'verfügbarkeit'],
|
||||
prestige: ['prestige', 'Prestige'],
|
||||
accessibility: ['accessibility', 'ÖV-Anbindung', 'ÖV', 'Erreichbarkeit'],
|
||||
expansionPotential:['expansionPotential', 'Expansionspotenzial'],
|
||||
flexibility: ['flexibility', 'Flexibilität'],
|
||||
visibility: ['visibility', 'Sichtbarkeit', 'visibilityScore'],
|
||||
footfall: ['footfall', 'Passantenfrequenz', 'passerbyFrequency'],
|
||||
talentAccess: ['talentAccess', 'Talent-Zugang', 'Talente'],
|
||||
esg: ['esg', 'ESG', 'Nachhaltigkeit'],
|
||||
taxEnvironment: ['taxEnvironment', 'Steuerlast', 'Steuerumfeld'],
|
||||
}
|
||||
|
||||
function getTitle(item: UnifiedMatchResult): string {
|
||||
const prop = getProp(item)
|
||||
const sig = getSig(item)
|
||||
return prop?.title ?? sig?.companyName ?? sig?.locationHint ?? item.matchId.slice(0, 8)
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
}
|
||||
|
||||
const DATA_SX = {
|
||||
borderLeft: '1px solid #f1f5f9',
|
||||
minWidth: 220,
|
||||
verticalAlign: 'top',
|
||||
py: 1.5,
|
||||
function row(label: string, cells: ReactNode[]) {
|
||||
return (
|
||||
<TableRow hover key={label}>
|
||||
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||
{cells.map((cell, i) => (
|
||||
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
@@ -136,8 +87,6 @@ export default function Compare() {
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
: []
|
||||
|
||||
const maxRelevantWeight = relevantCriteria[0]?.weight ?? 1
|
||||
|
||||
const weightedTotals = compareItems.map(item =>
|
||||
relevantCriteria.reduce((sum, { key, weight }) => {
|
||||
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
|
||||
@@ -177,32 +126,6 @@ export default function Compare() {
|
||||
)
|
||||
const maxMissingCritical = Math.max(...missingCriticalCounts)
|
||||
|
||||
// ── Shared render helpers ─────────────────────────────────────────────────
|
||||
|
||||
function row(label: string, cells: ReactNode[]) {
|
||||
return (
|
||||
<TableRow hover key={label}>
|
||||
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||
{cells.map((cell, i) => (
|
||||
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function scoreBar(value: number, label?: string) {
|
||||
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 70 }}>
|
||||
<LinearProgress variant="determinate" value={value * 100}
|
||||
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: color } }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color }}>{label ?? `${Math.round(value * 100)}%`}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<AddToPipelineDialog />
|
||||
@@ -233,88 +156,12 @@ export default function Compare() {
|
||||
|
||||
{/* Criteria Head-to-Head */}
|
||||
{activeNeed && relevantCriteria.length > 0 && (
|
||||
<Card sx={{ mb: 3, overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Trophy size={18} color="#d4920e" />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
|
||||
</Box>
|
||||
{overallWinnerIdx !== -1 && (
|
||||
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Trophy size={16} color="#d4920e" />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table sx={{ tableLayout: 'auto' }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
||||
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
||||
{compareItems.map(item => (
|
||||
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
|
||||
{getTitle(item)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{relevantCriteria.map(({ key, label, weight }) => {
|
||||
const allCriteria = (item: typeof compareItems[0]) =>
|
||||
item.match.allFactors ?? [...item.match.positiveFactors, ...item.match.negativeFactors]
|
||||
|
||||
const factors = compareItems.map(item =>
|
||||
allCriteria(item).find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
|
||||
)
|
||||
const numericScores = factors.map(f => f?.score ?? null)
|
||||
const presentScores = numericScores.filter((s): s is number => s !== null)
|
||||
const maxScore = presentScores.length > 0 ? Math.max(...presentScores) : 0
|
||||
const winnerIdx = compareItems.length > 1 && presentScores.length > 1 && numericScores.filter(s => s === maxScore).length === 1
|
||||
? numericScores.indexOf(maxScore)
|
||||
: -1
|
||||
|
||||
return (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell sx={LABEL_SX}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
|
||||
</TableCell>
|
||||
{factors.map((factor, idx) => (
|
||||
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
||||
{factor ? (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{scoreBar(factor.score / 100)}
|
||||
{idx === winnerIdx && (
|
||||
<Chip label="Besser" size="small"
|
||||
icon={<CheckCircle2 size={10} />}
|
||||
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
|
||||
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
|
||||
'& .MuiChip-label': { px: 0.75 } }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary"
|
||||
sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', lineHeight: 1.4 }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<MissingDataCell reason="Kein Score für dieses Kriterium" />
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Card>
|
||||
<CompareCriteriaCard
|
||||
compareItems={compareItems}
|
||||
relevantCriteria={relevantCriteria}
|
||||
overallWinnerIdx={overallWinnerIdx}
|
||||
activeNeed={activeNeed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card sx={{ overflowX: 'auto' }}>
|
||||
|
||||
Reference in New Issue
Block a user