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:
Benjamin Sutter
2026-05-23 23:36:03 +02:00
parent 72e4f08900
commit 22c195b4a5
28 changed files with 1615 additions and 1282 deletions
@@ -5,10 +5,10 @@ import { usePipelineStore } from '../../stores/pipelineStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
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' },
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
@@ -0,0 +1,100 @@
import { Box, Card, Chip, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'
import { Trophy, CheckCircle2 } from 'lucide-react'
import { LABEL_SX, DATA_SX, CRITERION_ALIASES, getTitle, scoreBar } from './compareUtils'
import type { WeightingKey } from '../../domain/needBuilder'
import { MissingDataCell } from './CompareCell'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
interface Props {
compareItems: UnifiedMatchResult[]
relevantCriteria: Array<{ key: string; label: string; weight: number }>
overallWinnerIdx: number
activeNeed: { companyName: string }
}
export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) {
return (
<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 }) => {
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>
)
}
+91
View File
@@ -0,0 +1,91 @@
import type { ReactNode } from 'react'
import { Box, LinearProgress, Typography } from '@mui/material'
import type { WeightingKey } from '../../domain/needBuilder'
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
// ── Helpers ───────────────────────────────────────────────────────────────────
export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
export const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
export function getProp(item: UnifiedMatchResult) {
return item.resultType !== 'FUTURE_AVAILABILITY'
? (item as VerifiedPortfolioResult | ExternalMarketResult).property
: null
}
export function getSig(item: UnifiedMatchResult) {
return item.resultType === 'FUTURE_AVAILABILITY'
? (item as FutureAvailabilityResult).signal
: null
}
export const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
export const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
export 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'],
}
export 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 ────────────────────────────────────────────────────────────
export 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,
}
export const DATA_SX = {
borderLeft: '1px solid #f1f5f9',
minWidth: 220,
verticalAlign: 'top',
py: 1.5,
}
// ── Score bar helper ──────────────────────────────────────────────────────────
export function scoreBar(value: number, label?: string): ReactNode {
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>
)
}
+1
View File
@@ -2,3 +2,4 @@ export { CompareEmptyState } from './CompareEmptyState'
export { CompareColumnHeader } from './CompareColumnHeader'
export { CompareCell, MissingDataCell } from './CompareCell'
export { AICompareSummary } from './AICompareSummary'
export { CompareCriteriaCard } from './CompareCriteriaCard'