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:
@@ -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' }}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user