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' }}>