fix: UX polish — score formula, role badges, compare view, page titles

- Score: remove hidden dataQuality/confidence modifiers from finalScore;
  formula now correctly shows hard×60% + soft×40% = displayed total
- "Ihr Objekt" badge: only shown for PROPERTY_MANAGER/ORGANIZATION_ADMIN
  in both IntelligenceMatchCard and MatchCardHeader (DEMAND_USER sees
  Verified Portfolio as a normal listing without ownership indicator)
- Compare: fix factor lookup to use allFactors (includes mid-range 45–70
  scores) so Prestige, Erreichbarkeit etc. no longer show "Nicht verfügbar"
- Compare: richer AI summary with overallAssessment, perPropertyAssessment
  (strengths/weaknesses/bestFor/keyRisk per property), and recommendation
- Compare/Results: pb:10 so CompareTray never overlaps last row of content
- AppShell: dynamic route names for /demand/results/:id → "Match Detail"
  and /demand/property/:id → "Objekt Detail" (no more UUID in header)
- MatchDetail: remove "Match EF9" debug chip from sticky nav
- LocationIntelligencePanel: comparable listings are now clickable links
  navigating to /demand/property/:id
- Comparable links: blue text + hover highlight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-22 21:05:36 +02:00
parent 9f7062d137
commit 76f9927c7c
11 changed files with 180 additions and 43 deletions
+70 -4
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight } from 'lucide-react'
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight, CheckCircle2, XCircle, Star } from 'lucide-react'
import type { ComparisonSummary } from '../../services/aiService'
interface Props {
@@ -44,6 +44,11 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{!isLoading && summary && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Overall assessment */}
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.5 }}>{summary.overallAssessment}</Typography>
</Box>
{/* Strongest option */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
@@ -106,12 +111,73 @@ export function AICompareSummary({ summary, isLoading }: Props) {
</Box>
)}
{/* Next step */}
{/* Per-property assessment */}
{summary.perPropertyAssessment.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>Objektbewertung</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
{summary.perPropertyAssessment.map(prop => (
<Box
key={prop.matchId}
sx={{
flex: '1 1 200px',
border: '1px solid #e2e8f0',
borderRadius: 1,
p: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f' }}>{prop.label}</Typography>
{/* Strengths */}
<Box>
{prop.strengths.map((s, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{s}</Typography>
</Box>
))}
</Box>
{/* Weaknesses */}
<Box>
{prop.weaknesses.map((w, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<XCircle size={12} color="#dc2626" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{w}</Typography>
</Box>
))}
</Box>
{/* Best for */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Star size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>
<strong>Am besten für:</strong> {prop.bestFor}
</Typography>
</Box>
{/* Key risk */}
{prop.keyRisk && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{prop.keyRisk}</Typography>
</Box>
)}
</Box>
))}
</Box>
</Box>
)}
{/* Recommendation */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: '1px solid #f1f5f9', mt: 0.5 }}>
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfohlener nächster Schritt</Typography>
<Typography variant="body2">{summary.recommendedNextStep}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfehlung</Typography>
<Typography variant="body2">{summary.recommendation}</Typography>
</Box>
</Box>
</Box>
+3 -1
View File
@@ -131,7 +131,9 @@ function getPageNameFromPath(pathname: string): string {
if (item.path === pathname) return item.label
}
}
// Fallback: last segment, capitalised
if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail'
if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail'
if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail'
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
}
@@ -13,6 +13,7 @@ import {
User,
} from 'lucide-react'
import { useNavigate } from 'react-router'
import { useSessionStore } from '../../stores/sessionStore'
import { LocationPreview } from '../shared/LocationPreview'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import type { MatchCardViewModel } from './MatchCardViewModel'
@@ -334,6 +335,8 @@ interface Props {
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore()
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
@@ -379,7 +382,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && (
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={9} color="#1e3a5f" />}
label="Ihr Objekt"
@@ -1,6 +1,7 @@
import { Box, Chip } from '@mui/material'
import { Building2 } from 'lucide-react'
import { MatchScoreDisplay } from './MatchScoreDisplay'
import { useSessionStore } from '../../stores/sessionStore'
import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
@@ -22,6 +23,8 @@ interface Props {
}
export function MatchCardHeader({ vm, compact }: Props) {
const { currentUser } = useSessionStore()
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const confPct = Math.round(vm.confidenceScore * 100)
@@ -42,7 +45,7 @@ export function MatchCardHeader({ vm, compact }: Props) {
size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
/>
{vm.resultType === 'VERIFIED_PORTFOLIO' && (
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={10} color="#1e3a5f" />}
label="Ihr Objekt"
@@ -3,6 +3,7 @@ import {
Activity, Building2, HardHat, MapPin, Percent,
TrendingDown, TrendingUp, Train, Users, Zap,
} from 'lucide-react'
import { useNavigate } from 'react-router'
import { useProperties } from '../../hooks/useProperties'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
@@ -124,6 +125,7 @@ interface Props {
}
export function LocationIntelligencePanel({ property }: Props) {
const navigate = useNavigate()
const { data: allProperties = [] } = useProperties()
if (!property) return null
@@ -332,11 +334,16 @@ export function LocationIntelligencePanel({ property }: Props) {
{comparables.map(p => (
<Box
key={p.id}
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}
onClick={() => navigate(`/demand/property/${p.id}`)}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75,
borderBottom: '1px solid #f1f5f9', cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc', borderRadius: 0.5 },
}}
>
<Building2 size={13} color="#64748b" />
<Building2 size={13} color="#3b82f6" />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#1d4ed8' }}>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary">
@@ -114,6 +114,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
const baseSum = Math.round((hardContrib + softContrib) * 10) / 10
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
@@ -195,22 +196,22 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib} = {sb.totalScore}
{' = '}{hardContrib} + {softContrib} = {baseSum}
</Typography>
</Box>
{/* Total */}
<Box sx={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
bgcolor: sb.totalScore >= 78 ? '#f0fdf4' : sb.totalScore >= 52 ? '#fffbeb' : '#fef2f2',
bgcolor: baseSum >= 78 ? '#f0fdf4' : baseSum >= 52 ? '#fffbeb' : '#fef2f2',
p: 1.5, borderRadius: 1,
}}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
sx={{ fontWeight: 800, color: scoreTextColor(Math.round(baseSum)) }}
>
{sb.totalScore}/100
{baseSum}/100
</Typography>
</Box>
</>