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:
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
|
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'
|
import type { ComparisonSummary } from '../../services/aiService'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,6 +44,11 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
|
|
||||||
{!isLoading && summary && (
|
{!isLoading && summary && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<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 */}
|
{/* Strongest option */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
@@ -106,12 +111,73 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
</Box>
|
</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 }}>
|
<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 }} />
|
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfohlener nächster Schritt</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfehlung</Typography>
|
||||||
<Typography variant="body2">{summary.recommendedNextStep}</Typography>
|
<Typography variant="body2">{summary.recommendation}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ function getPageNameFromPath(pathname: string): string {
|
|||||||
if (item.path === pathname) return item.label
|
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() ?? ''
|
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
|
||||||
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
@@ -334,6 +335,8 @@ interface Props {
|
|||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
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 rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
@@ -379,7 +382,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
<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 }} />
|
<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
|
<Chip
|
||||||
icon={<Building2 size={9} color="#1e3a5f" />}
|
icon={<Building2 size={9} color="#1e3a5f" />}
|
||||||
label="Ihr Objekt"
|
label="Ihr Objekt"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Chip } from '@mui/material'
|
import { Box, Chip } from '@mui/material'
|
||||||
import { Building2 } from 'lucide-react'
|
import { Building2 } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
@@ -22,6 +23,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardHeader({ vm, compact }: 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 rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const confPct = Math.round(vm.confidenceScore * 100)
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function MatchCardHeader({ vm, compact }: Props) {
|
|||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && (
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Building2 size={10} color="#1e3a5f" />}
|
icon={<Building2 size={10} color="#1e3a5f" />}
|
||||||
label="Ihr Objekt"
|
label="Ihr Objekt"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Activity, Building2, HardHat, MapPin, Percent,
|
Activity, Building2, HardHat, MapPin, Percent,
|
||||||
TrendingDown, TrendingUp, Train, Users, Zap,
|
TrendingDown, TrendingUp, Train, Users, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
@@ -124,6 +125,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LocationIntelligencePanel({ property }: Props) {
|
export function LocationIntelligencePanel({ property }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { data: allProperties = [] } = useProperties()
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
|
||||||
if (!property) return null
|
if (!property) return null
|
||||||
@@ -332,11 +334,16 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
{comparables.map(p => (
|
{comparables.map(p => (
|
||||||
<Box
|
<Box
|
||||||
key={p.id}
|
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 }}>
|
<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}
|
{p.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<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 hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
|
||||||
const softContrib = Math.round(sb.softFactorScore * 0.40 * 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 maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
|
||||||
const maxSoftWeight = Math.max(...softFactors.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>
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
|
||||||
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
||||||
{' = '}{hardContrib} + {softContrib} = {sb.totalScore}
|
{' = '}{hardContrib} + {softContrib} = {baseSum}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
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,
|
p: 1.5, borderRadius: 1,
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h4"
|
variant="h4"
|
||||||
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
|
sx={{ fontWeight: 800, color: scoreTextColor(Math.round(baseSum)) }}
|
||||||
>
|
>
|
||||||
{sb.totalScore}/100
|
{baseSum}/100
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -586,14 +586,9 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
]
|
]
|
||||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||||
|
|
||||||
// ── Modifiers ──────────────────────────────────────────────────────────────
|
// ── Final score: purely hard/soft weighted sum + must-have penalty ──────────
|
||||||
const dqMod = calcDataQualityModifier(property)
|
|
||||||
const confMod = calcConfidenceModifier(property)
|
|
||||||
|
|
||||||
// ── Final score: weighted sum of both groups + modifiers + must-have penalty
|
|
||||||
// Each group already normalized 0–100; combine per SCORE_SPLIT, then apply modifiers
|
|
||||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
||||||
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
||||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||||
|
|
||||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||||
@@ -621,8 +616,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
finalScore,
|
finalScore,
|
||||||
hardMatchScore,
|
hardMatchScore,
|
||||||
softFactorScore,
|
softFactorScore,
|
||||||
dataQualityModifier: dqMod,
|
dataQualityModifier: 0,
|
||||||
confidenceModifier: confMod,
|
confidenceModifier: 0,
|
||||||
positiveFactors,
|
positiveFactors,
|
||||||
negativeFactors,
|
negativeFactors,
|
||||||
allHardFactors: hardFactors,
|
allHardFactors: hardFactors,
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default function Compare() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<Box sx={{ display: { xs: 'none', md: 'block' }, px: 3, py: 3 }}>
|
<Box sx={{ display: { xs: 'none', md: 'block' }, px: 3, py: 3, pb: 10 }}>
|
||||||
|
|
||||||
{/* AI Summary */}
|
{/* AI Summary */}
|
||||||
<AICompareSummary summary={aiSummary} isLoading={aiLoading && compareItems.length >= 2} />
|
<AICompareSummary summary={aiSummary} isLoading={aiLoading && compareItems.length >= 2} />
|
||||||
@@ -263,9 +263,11 @@ export default function Compare() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{relevantCriteria.map(({ key, label, weight }) => {
|
{relevantCriteria.map(({ key, label, weight }) => {
|
||||||
|
const allCriteria = (item: typeof compareItems[0]) =>
|
||||||
|
item.match.allFactors ?? [...item.match.positiveFactors, ...item.match.negativeFactors]
|
||||||
|
|
||||||
const factors = compareItems.map(item =>
|
const factors = compareItems.map(item =>
|
||||||
[...item.match.positiveFactors, ...item.match.negativeFactors]
|
allCriteria(item).find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
|
||||||
.find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
|
|
||||||
)
|
)
|
||||||
const numericScores = factors.map(f => f?.score ?? null)
|
const numericScores = factors.map(f => f?.score ?? null)
|
||||||
const presentScores = numericScores.filter((s): s is number => s !== null)
|
const presentScores = numericScores.filter((s): s is number => s !== null)
|
||||||
@@ -278,11 +280,6 @@ export default function Compare() {
|
|||||||
<TableRow key={key} hover>
|
<TableRow key={key} hover>
|
||||||
<TableCell sx={LABEL_SX}>
|
<TableCell sx={LABEL_SX}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
|
<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>
|
</TableCell>
|
||||||
{factors.map((factor, idx) => (
|
{factors.map((factor, idx) => (
|
||||||
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
||||||
|
|||||||
@@ -233,11 +233,6 @@ export default function MatchDetail() {
|
|||||||
>
|
>
|
||||||
Zurück zu Resultaten
|
Zurück zu Resultaten
|
||||||
</Button>
|
</Button>
|
||||||
<Chip
|
|
||||||
label={`Match ${match.id.slice(-3).toUpperCase()}`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 700, letterSpacing: 0.5 }}
|
|
||||||
/>
|
|
||||||
{isFuture && (
|
{isFuture && (
|
||||||
<Chip label="Future Availability Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
|
<Chip label="Future Availability Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function Results() {
|
|||||||
|
|
||||||
const { data: results = [], isLoading } = useUnifiedResults(effectiveNeedId)
|
const { data: results = [], isLoading } = useUnifiedResults(effectiveNeedId)
|
||||||
|
|
||||||
const isStaff = true // all roles see portfolio properties in demo
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
const filtered = results.filter(r => {
|
const filtered = results.filter(r => {
|
||||||
if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties
|
if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties
|
||||||
@@ -123,7 +123,7 @@ export default function Results() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||||
|
|||||||
@@ -53,9 +53,24 @@ export interface ComparisonSummary {
|
|||||||
biggestTradeoffs: string[]
|
biggestTradeoffs: string[]
|
||||||
missingDataWarnings: string[]
|
missingDataWarnings: string[]
|
||||||
recommendedNextStep: string
|
recommendedNextStep: string
|
||||||
|
overallAssessment: string
|
||||||
|
perPropertyAssessment: Array<{
|
||||||
|
matchId: string
|
||||||
|
label: string
|
||||||
|
strengths: string[]
|
||||||
|
weaknesses: string[]
|
||||||
|
bestFor: string
|
||||||
|
keyRisk: string | null
|
||||||
|
}>
|
||||||
|
recommendation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
||||||
|
const getTitle = (item: UnifiedMatchResult) =>
|
||||||
|
item.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
||||||
|
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return {
|
return {
|
||||||
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
||||||
@@ -64,14 +79,12 @@ function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary
|
|||||||
biggestTradeoffs: [],
|
biggestTradeoffs: [],
|
||||||
missingDataWarnings: [],
|
missingDataWarnings: [],
|
||||||
recommendedNextStep: 'Suchergebnisse überprüfen',
|
recommendedNextStep: 'Suchergebnisse überprüfen',
|
||||||
|
overallAssessment: 'Keine Ergebnisse für die Analyse verfügbar.',
|
||||||
|
perPropertyAssessment: [],
|
||||||
|
recommendation: 'Suchergebnisse überprüfen und erneut versuchen.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTitle = (item: UnifiedMatchResult) =>
|
|
||||||
item.resultType !== 'FUTURE_AVAILABILITY'
|
|
||||||
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
|
||||||
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
|
||||||
|
|
||||||
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
||||||
|
|
||||||
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||||
@@ -95,6 +108,58 @@ function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary
|
|||||||
|
|
||||||
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
||||||
|
|
||||||
|
// Overall assessment
|
||||||
|
const labels = items.map(getTitle)
|
||||||
|
const scoreLeader = getTitle(strongest)
|
||||||
|
const priceLeader = bestValue ? getTitle(bestValue) : null
|
||||||
|
let overallAssessment: string
|
||||||
|
if (items.length === 1) {
|
||||||
|
overallAssessment = `${labels[0]} erfüllt die Kernkriterien mit einem Match Score von ${strongest.matchScore}/100. Eine Vergleichsbasis fehlt — weitere Optionen hinzufügen für eine fundierte Entscheidung.`
|
||||||
|
} else if (priceLeader && priceLeader !== scoreLeader) {
|
||||||
|
overallAssessment = `Beide Optionen erfüllen Standort und Fläche gut. ${scoreLeader} führt beim Match Score und Prestige, ${priceLeader} beim Preis-Leistungs-Verhältnis.`
|
||||||
|
} else {
|
||||||
|
overallAssessment = `${scoreLeader} dominiert in den meisten Kriterien mit dem höchsten Match Score (${strongest.matchScore}/100). Die Optionen unterscheiden sich hauptsächlich in Lage und Ausstattung.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-property assessment
|
||||||
|
const deriveBestFor = (item: UnifiedMatchResult): string => {
|
||||||
|
const assetType = (item as any).property?.assetType ?? ''
|
||||||
|
const score = item.matchScore
|
||||||
|
const hasPosPrestige = item.match.positiveFactors.some(f =>
|
||||||
|
f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location')
|
||||||
|
)
|
||||||
|
const hasLowPrice = bestValue?.matchId === item.matchId
|
||||||
|
if (hasPosPrestige && score >= 80) return 'Repräsentationsbedarf mit Prestige-Anforderungen'
|
||||||
|
if (hasLowPrice) return 'Kostenoptimierte Wachstumsphase'
|
||||||
|
if (assetType === 'LOGISTICS' || assetType === 'PRODUCTION') return 'Operative Effizienz und Flächenflexibilität'
|
||||||
|
if (assetType === 'RETAIL') return 'Hohe Kundenfrequenz und Sichtbarkeit'
|
||||||
|
if (score >= 80) return 'Anspruchsvolle Anforderungen mit hoher Matchqualität'
|
||||||
|
return 'Ausgewogenes Preis-Leistungs-Profil'
|
||||||
|
}
|
||||||
|
|
||||||
|
const perPropertyAssessment = items.map(item => {
|
||||||
|
const topStrengths = item.match.positiveFactors
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(f => f.explanation)
|
||||||
|
const topWeaknesses = item.match.negativeFactors.length > 0
|
||||||
|
? item.match.negativeFactors.slice(0, 2).map(f => f.explanation)
|
||||||
|
: ['Keine kritischen Schwächen identifiziert']
|
||||||
|
const keyRisk = item.match.risks?.[0]?.description ?? null
|
||||||
|
return {
|
||||||
|
matchId: item.matchId,
|
||||||
|
label: getTitle(item),
|
||||||
|
strengths: topStrengths,
|
||||||
|
weaknesses: topWeaknesses,
|
||||||
|
bestFor: deriveBestFor(item),
|
||||||
|
keyRisk,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recommendation
|
||||||
|
const recommendation = items.length >= 2
|
||||||
|
? `Besichtigung beider Objekte empfohlen — danach Entscheidung auf Basis Mietvertragslaufzeit und Ausbaustandard.`
|
||||||
|
: `Besichtigung von ${getTitle(strongest)} empfohlen — anschliessend Vertragskonditionen und Laufzeit prüfen.`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
strongestOption: {
|
strongestOption: {
|
||||||
matchId: strongest.matchId,
|
matchId: strongest.matchId,
|
||||||
@@ -116,6 +181,9 @@ function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary
|
|||||||
biggestTradeoffs: tradeoffs,
|
biggestTradeoffs: tradeoffs,
|
||||||
missingDataWarnings: missingWarnings,
|
missingDataWarnings: missingWarnings,
|
||||||
recommendedNextStep: topNextAction,
|
recommendedNextStep: topNextAction,
|
||||||
|
overallAssessment,
|
||||||
|
perPropertyAssessment,
|
||||||
|
recommendation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user