feat: Steuerlast-Kriterium, ImmoScout-Layout, Gold/Silver/Bronze-Trennung

- Flächensuche: Flexibilität durch Steuerlast (taxEnvironment) ersetzt
- MatchDetail: Bild als Hero-Banner oben, Karte eingebettet im Inhalt darunter
- Ergebnisliste: Gold/Silber/Bronze-Abschnitte mit farbigen Trennlinien
- ScoreBreakdown: KI-Steuerlast-Link zur kantonalen Steuerrechner-Seite
- Beispieldaten: alle Objekte mit passenden Bildern versehen
- locationIntelligence: taxCalculatorUrl pro Kanton ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 14:14:10 +02:00
parent 5b917bd619
commit 05ac0083b2
13 changed files with 495 additions and 91 deletions
+140
View File
@@ -19,6 +19,9 @@ import { useNavigate } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useCompareStore } from '../../stores/compareStore'
import { aiService } from '../../services/aiService'
import { needService } from '../../services/needService'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import {
CompareEmptyState,
CompareColumnHeader,
@@ -54,6 +57,23 @@ const TYPE_META: Record<string, { label: string; color: string }> = {
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'],
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 = {
@@ -92,6 +112,37 @@ export default function Compare() {
staleTime: Infinity,
})
const { data: needsData } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
select: r => r.data,
})
const activeNeed = needsData
? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
: undefined
const relevantCriteria = activeNeed
? WEIGHTING_KEYS
.map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 }))
.filter(c => c.weight > 0)
.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]
.find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
return sum + (factor?.score ?? 50) * weight
}, 0)
)
const maxWeightedTotal = Math.max(...weightedTotals)
const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1
? weightedTotals.indexOf(maxWeightedTotal)
: -1
if (compareItems.length === 0) {
return (
<Box>
@@ -171,6 +222,95 @@ export default function Compare() {
{/* AI Summary */}
<AICompareSummary summary={aiSummary} isLoading={aiLoading && compareItems.length >= 2} />
{/* 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 factors = compareItems.map(item =>
[...item.match.positiveFactors, ...item.match.negativeFactors]
.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>
<Box sx={{ display: 'flex', gap: 0.25, mt: 0.5 }}>
{[1, 2, 3, 4, 5].map(i => (
<Box key={i} sx={{ width: 5, height: 5, borderRadius: '50%', bgcolor: i <= Math.ceil((weight / maxRelevantWeight) * 5) ? '#1e3a5f' : '#e2e8f0' }} />
))}
</Box>
</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>
)}
<Card sx={{ overflowX: 'auto' }}>
<Table sx={{ tableLayout: 'auto' }}>
+198 -63
View File
@@ -1,4 +1,5 @@
import { Box, CircularProgress, Paper, Typography } from '@mui/material'
import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -9,12 +10,12 @@ import { useCompareStore } from '../../stores/compareStore'
import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { LocationPreview, PropertyMap } from '../../components/shared'
import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
import { PropertyMap } from '../../components/shared'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import {
LocationIntelligencePanel,
MatchDetailHeader,
ExecutiveSummaryPanel,
PropertyOverviewPanel,
NeedAlignmentPanel,
ScoreBreakdownPanel,
TradeoffPanel,
@@ -28,6 +29,12 @@ import type { MatchCardReason } from '../../components/match-card/MatchCardViewM
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: 'Marktinserat', color: '#d97706' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', 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'],
@@ -37,6 +44,13 @@ function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data
}))
}
function confColor(c: number) {
return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
}
function dqColor(q: number) {
return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
}
export default function MatchDetail() {
const { matchId } = useParams<{ matchId: string }>()
const navigate = useNavigate()
@@ -44,10 +58,8 @@ export default function MatchDetail() {
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),
@@ -82,47 +94,49 @@ export default function MatchDetail() {
<Box sx={{ px: 3, py: 4 }}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary">Match nicht gefunden</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Das gesuchte Match existiert nicht oder wurde entfernt.
</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 ?? ''
const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
const confPct = Math.round(match.confidenceLevel * 100)
const dqPct = Math.round(dqScore * 100)
// 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,
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,
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, signal,
})
}
navigate('/demand/compare')
}
const handleBack = () => navigate(-1)
const handleShortlist = () => {
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id
openAddDialog({
resultId: match.id,
resultType: match.resultType ?? 'VERIFIED_PORTFOLIO',
title,
title: property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id,
matchScore: match.matchScore,
confidenceScore: match.confidenceLevel,
sourceLabel: property?.sourceLabel ?? match.resultType ?? 'VERIFIED_PORTFOLIO',
@@ -131,52 +145,154 @@ export default function MatchDetail() {
})
}
return (
<Box sx={{ px: 3, py: 2 }}>
<AddToShortlistDialog />
<MatchDetailHeader
match={match}
property={property}
signal={signal}
onBack={handleBack}
onCompare={handleCompare}
onShortlist={handleShortlist}
/>
// 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 ?? '' },
]
{/* Photo + Map */}
{!isFuture && property && (
<Box sx={{ mb: 2, borderRadius: 2, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
{property.images?.[0] && (
<LocationPreview
imageUrl={property.images[0]}
lat={property.location.coordinates?.lat}
lng={property.location.coordinates?.lng}
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
cityLabel={property.location.city}
height={200}
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="Probabilistisches 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' }}
/>
)}
{property.location.coordinates && (
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={280}
/>
)}
</Box>
</Box>
) : property?.location?.coordinates ? (
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={340}
/>
) : null
)}
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{/* Property header — white section below hero */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ px: 3, pt: 2.5, pb: 2 }}>
<Box sx={{ display: 'flex', alignItems: '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' }} />
)}
<Chip
label={`${confPct}% Konfidenz`}
size="small"
sx={{ bgcolor: confColor(match.confidenceLevel), color: 'white', fontSize: '0.72rem' }}
/>
<Chip
label={`DQ ${dqPct}%`}
size="small"
sx={{ bgcolor: dqColor(dqScore), color: 'white', 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', px: 3, py: 1.5, gap: 0 }}>
{keyFacts.map((fact, i) => (
<Box key={fact.label} sx={{
flex: 1,
pl: i === 0 ? 0 : 2,
pr: 2,
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: 3, py: 3, display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{/* Main column */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ExecutiveSummaryPanel match={match} />
<PropertyOverviewPanel match={match} property={property} signal={signal} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{/* Why It Matches — structured from scoreFactors */}
{reasons.length > 0 && (
<Paper sx={{ p: 2.5, mb: 2 }}>
<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 && (
@@ -197,6 +313,25 @@ export default function MatchDetail() {
)}
<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} />
@@ -204,9 +339,9 @@ export default function MatchDetail() {
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
</Box>
{/* Sidebar — sticky */}
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 24 }}>
<ScoreBreakdownPanel match={match} />
{/* Sidebar */}
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} />
<NextActionsPanel
match={match}
onCompare={handleCompare}