Files
property-match/src/pages/demand/MatchDetail.tsx
T
Benjamin Sutter 27c53f3af1 feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore
  exclusively — allFactors always populated, no more dual-system (computeScore
  removed), weights from weightingProfile reflected in every breakdown
- ScoreInlineBreakdown: new component shows hard/soft criteria with importance
  labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards
- MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel
- MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability'
- aiService budget parser: values < 100 treated as monthly and multiplied by 12
  to produce correct annual CHF/m²/year value — fixes 0-result searches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:41:46 +02:00

562 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, ShieldCheck, Tag, Train, TrendingUp } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import type { PropertyUnit } from '../../domain/property'
import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from '../../hooks/useMatches'
import { propertyService } from '../../services/propertyService'
import { needService } from '../../services/needService'
import { futureSignalService } from '../../services/futureSignalService'
import { useCompareStore } from '../../stores/compareStore'
import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
import { PropertyMap } from '../../components/shared'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import {
LocationIntelligencePanel,
ExecutiveSummaryPanel,
NeedAlignmentPanel,
ScoreBreakdownPanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
SourceProvenancePanel,
FutureAvailabilityContextPanel,
NextActionsPanel,
} from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
// ── Property detail helpers ────────────────────────────────────────────────────
const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
}
const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
const SOURCE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice',
NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst',
}
const PASSERBY_LABELS: Record<string, string> = {
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
}
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
</Box>
)
}
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
if (unit.schattenmarktRelease?.enabled) {
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
}
if (unit.available) {
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
}
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
}
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
return (
<Box sx={{
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
</Typography>
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : ''}</Typography>
<Typography variant="body2" color="text.secondary">
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : ''}
</Typography>
<UnitStatusChip unit={unit} />
</Box>
)
}
// ── Match helpers ──────────────────────────────────────────────────────────────
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
const RESULT_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: 'Future Availability', color: '#7c3aed' },
}
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
return match.positiveFactors.slice(0, 3).map(f => ({
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
label: f.criterion.charAt(0).toUpperCase() + f.criterion.slice(1),
explanation: f.explanation,
score: f.score,
}))
}
export default function MatchDetail() {
const { matchId } = useParams<{ matchId: string }>()
const navigate = useNavigate()
const { addToCompare } = useCompareStore()
const { openAddDialog } = useShortlistStore()
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
const { data: property = null } = useQuery({
queryKey: ['property', match?.propertyId],
queryFn: () => propertyService.getById(match!.propertyId),
enabled: !!match && !isFuture,
select: r => r.data ?? null,
})
const { data: need = null } = useQuery({
queryKey: ['need', match?.needId],
queryFn: () => needService.getById(match!.needId),
enabled: !!match?.needId,
select: r => r.data ?? null,
})
const { data: signal = null } = useQuery({
queryKey: ['signal', match?.resultId],
queryFn: () => futureSignalService.getById(match!.resultId!),
enabled: !!match && isFuture && !!match.resultId,
select: r => r.data ?? null,
})
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
if (!match) {
return (
<Box sx={{ px: 3, py: 4 }}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary">Match nicht gefunden</Typography>
</Paper>
</Box>
)
}
const reasons = buildReasons(match)
const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '', color: '#64748b' }
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? ''
const city = property?.location?.city
const location = city
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
: signal?.locationHint ?? ''
// Tax calculator — only shown when AI evaluated taxEnvironment
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
const cityIntel = city ? getCityIntelligence(city) : null
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const handleCompare = () => {
if (match && !isFuture && property) {
addToCompare({
resultType: property.resultType === 'EXTERNAL_MARKET' ? 'EXTERNAL_MARKET' : 'VERIFIED_PORTFOLIO',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
})
} else if (match && isFuture && signal) {
addToCompare({
resultType: 'FUTURE_AVAILABILITY',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, signal,
})
}
navigate('/demand/compare')
}
const handleShortlist = () => {
openAddDialog({
resultId: match.id,
resultType: match.resultType ?? 'VERIFIED_PORTFOLIO',
title: property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id,
matchScore: match.matchScore,
confidenceScore: match.confidenceLevel,
sourceLabel: property?.sourceLabel ?? match.resultType ?? 'VERIFIED_PORTFOLIO',
addedBy: 'admin@ideal-sharing.ch',
propertyId: property?.id,
})
}
// Key facts for the strip below the hero
const keyFacts = isFuture ? [
{ label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')}` : '' },
{ label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '' },
{ label: 'Wahrscheinlichkeit', value: signal?.probability ? `${Math.round(signal.probability * 100)}%` : '' },
] : [
{ label: 'Nutzfläche', value: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : '' },
{ label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '' },
{ label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ label: 'Nutzungsart', value: property?.assetType ?? '' },
]
return (
<Box sx={{ bgcolor: '#f1f5f9', minHeight: '100vh' }}>
<AddToShortlistDialog />
{/* Sticky back nav */}
<Box sx={{
px: 3, py: 1.25, bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
position: 'sticky', top: 0, zIndex: 100,
display: 'flex', alignItems: 'center', gap: 2,
}}>
<Button
startIcon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
size="small"
sx={{ color: '#64748b', fontWeight: 500, textTransform: 'none' }}
>
Zurück zu Resultaten
</Button>
<Chip
label={`Match ${match.id.slice(-3).toUpperCase()}`}
size="small"
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 700, letterSpacing: 0.5 }}
/>
{isFuture && (
<Chip label="Future Availability Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
)}
</Box>
{/* Hero: image first, map fallback */}
{!isFuture && (
property?.images?.[0] ? (
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e2e8f0', flexShrink: 0 }}>
<img
src={property.images[0]}
alt={property.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
) : property?.location?.coordinates ? (
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={340}
/>
) : null
)}
{/* Property header — white section below hero */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 2.5, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'flex-start', sm: 'flex-start' }, justifyContent: 'space-between', gap: 2 }}>
{/* Left: title + address + chips */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25 }}>
{location}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: '0.72rem' }} />
{property?.assetType && (
<Chip label={property.assetType} size="small" variant="outlined" sx={{ fontSize: '0.72rem' }} />
)}
</Box>
</Box>
{/* Right: score + actions */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 1.5, flexShrink: 0 }}>
<MatchScoreDisplay score={match.matchScore} size="lg" />
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Bookmark size={14} />}
onClick={handleShortlist}
sx={{ textTransform: 'none' }}
>
Shortlist
</Button>
<Button
variant="contained"
size="small"
startIcon={<Columns2 size={14} />}
onClick={handleCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
Vergleichen
</Button>
</Box>
</Box>
</Box>
</Box>
{/* Key facts strip */}
<Divider />
<Box sx={{ display: 'flex', flexWrap: 'wrap', px: { xs: 2, sm: 3 }, py: 1.5, gap: 0 }}>
{keyFacts.map((fact, i) => (
<Box key={fact.label} sx={{
flex: '1 1 120px',
pl: i === 0 ? 0 : { xs: 1.5, sm: 2 },
pr: { xs: 1.5, sm: 2 },
py: { xs: 0.5, sm: 0 },
borderLeft: i === 0 ? 'none' : '1px solid #e2e8f0',
}}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>
{fact.label}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, mt: 0.25 }}>
{fact.value}
</Typography>
</Box>
))}
</Box>
</Box>
{/* Main content */}
<Box sx={{ px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, alignItems: 'flex-start' }}>
{/* Main column */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{/* ── Property Details ── */}
{!isFuture && property && (() => {
const units = property.units ?? []
const matchedUnit = units.find(u => u.id === match.unitId)
const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null)
const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined)
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType
return (
<>
{/* Preis */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`} />
{property.ancillaryCosts != null && (
<KeyFactRow label="Nebenkosten" value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`} />
)}
</Paper>
{/* Hauptangaben */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')}`} />
{minLettable != null && <KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />}
{property.contractDurationMonths != null && <KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />}
{(property.floorLevel != null || matchedUnit) && (
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL((matchedUnit?.floorLevel ?? property.floorLevel)!)} />
)}
{property.currentTenant && <KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />}
{property.leaseEndDate && <KeyFactRow label="Mietvertragsende" value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })} />}
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />}
</Paper>
{/* Eigenschaften */}
{property.softFactors && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
)}
{property.softFactors.passerbyFrequency && (
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
)}
</Box>
</Paper>
)}
{/* Wegzeit */}
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
<Typography variant="caption" color="text.secondary">Nächster ÖV-Anschluss {property.location.city}</Typography>
</Box>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Die Zeiten beziehen sich auf die Strecke zu Fuss.
</Typography>
</Paper>
)}
{/* Einheiten */}
{units.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
))}
</Box>
{preMarketUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId || preMarketUnits.length === 1} />)}
{otherUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId} />)}
</Paper>
)}
{/* Beschreibung */}
{property.description && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
)}
{/* Quelle & Referenz */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
{property.propertyNumber && <KeyFactRow label="Objektnummer" value={property.propertyNumber} />}
{property.importedFrom && <KeyFactRow label="Importiert aus" value={property.importedFrom} />}
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
)}
{property.sourceUrl && (
<Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
Zum Originalinserat
</Button>
</Box>
)}
</Paper>
</>
)
})()}
{reasons.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
<MatchReasonList reasons={reasons} maxItems={3} />
{match.negativeFactors.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Schwächere Faktoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{match.negativeFactors.slice(0, 3).map((f, i) => (
<Typography key={i} variant="body2" color="text.secondary">
· {f.criterion}: {f.explanation}
</Typography>
))}
</Box>
</Box>
)}
</Paper>
)}
<LocationIntelligencePanel property={property} />
{/* Map — always show when image was the hero above */}
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/>
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
<SourceProvenancePanel property={property} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
</Box>
{/* Sidebar */}
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
<NextActionsPanel
match={match}
onCompare={handleCompare}
onShortlist={handleShortlist}
onReview={() => {}}
onReject={() => {}}
/>
</Box>
</Box>
</Box>
)
}