From 76f9927c7ce55065be4c119168d242e09055e4ef Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 22 May 2026 21:05:36 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20UX=20polish=20=E2=80=94=20score=20formul?= =?UTF-8?q?a,=20role=20badges,=20compare=20view,=20page=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/compare/AICompareSummary.tsx | 74 +++++++++++++++++- src/components/layout/AppShell.tsx | 4 +- .../match-card/IntelligenceMatchCard.tsx | 5 +- src/components/match-card/MatchCardHeader.tsx | 5 +- .../LocationIntelligencePanel.tsx | 13 +++- .../match-detail/ScoreBreakdownPanel.tsx | 9 ++- src/features/matching/scoreCalculator.ts | 13 +--- src/pages/demand/Compare.tsx | 13 ++-- src/pages/demand/MatchDetail.tsx | 5 -- src/pages/demand/Results.tsx | 4 +- src/services/aiService.ts | 78 +++++++++++++++++-- 11 files changed, 180 insertions(+), 43 deletions(-) diff --git a/src/components/compare/AICompareSummary.tsx b/src/components/compare/AICompareSummary.tsx index 8ffcf8b..49192f8 100644 --- a/src/components/compare/AICompareSummary.tsx +++ b/src/components/compare/AICompareSummary.tsx @@ -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 && ( + {/* Overall assessment */} + + {summary.overallAssessment} + + {/* Strongest option */} @@ -106,12 +111,73 @@ export function AICompareSummary({ summary, isLoading }: Props) { )} - {/* Next step */} + {/* Per-property assessment */} + {summary.perPropertyAssessment.length > 0 && ( + + Objektbewertung + + {summary.perPropertyAssessment.map(prop => ( + + {prop.label} + + {/* Strengths */} + + {prop.strengths.map((s, i) => ( + + + {s} + + ))} + + + {/* Weaknesses */} + + {prop.weaknesses.map((w, i) => ( + + + {w} + + ))} + + + {/* Best for */} + + + + Am besten für: {prop.bestFor} + + + + {/* Key risk */} + {prop.keyRisk && ( + + + {prop.keyRisk} + + )} + + ))} + + + )} + + {/* Recommendation */} - Empfohlener nächster Schritt - {summary.recommendedNextStep} + Empfehlung + {summary.recommendation} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index e6aa327..a4544ff 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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, ' ') } diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 51eb95e..23d7b15 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -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 - {vm.resultType === 'VERIFIED_PORTFOLIO' && ( + {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && ( } label="Ihr Objekt" diff --git a/src/components/match-card/MatchCardHeader.tsx b/src/components/match-card/MatchCardHeader.tsx index 26f1c52..a0d344f 100644 --- a/src/components/match-card/MatchCardHeader.tsx +++ b/src/components/match-card/MatchCardHeader.tsx @@ -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 = { @@ -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 && ( } label="Ihr Objekt" diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx index 689d443..2a1705d 100644 --- a/src/components/match-detail/LocationIntelligencePanel.tsx +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -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 => ( 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 }, + }} > - + - + {p.title} diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx index 647362c..d7d323f 100644 --- a/src/components/match-detail/ScoreBreakdownPanel.tsx +++ b/src/components/match-detail/ScoreBreakdownPanel.tsx @@ -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) Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40% - {' = '}{hardContrib} + {softContrib} = {sb.totalScore} + {' = '}{hardContrib} + {softContrib} = {baseSum} {/* Total */} = 78 ? '#f0fdf4' : sb.totalScore >= 52 ? '#fffbeb' : '#fef2f2', + bgcolor: baseSum >= 78 ? '#f0fdf4' : baseSum >= 52 ? '#fffbeb' : '#fef2f2', p: 1.5, borderRadius: 1, }}> Gesamt-Score - {sb.totalScore}/100 + {baseSum}/100 diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts index 6703323..20321af 100644 --- a/src/features/matching/scoreCalculator.ts +++ b/src/features/matching/scoreCalculator.ts @@ -586,14 +586,9 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu ] const mustHaveEval = scoreMustHaves(allMustHaveText, property) - // ── Modifiers ────────────────────────────────────────────────────────────── - 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 + // ── Final score: purely hard/soft weighted sum + must-have penalty ────────── 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))) // ── Factor classification — only use weighted soft factors for positive/negative ── @@ -621,8 +616,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu finalScore, hardMatchScore, softFactorScore, - dataQualityModifier: dqMod, - confidenceModifier: confMod, + dataQualityModifier: 0, + confidenceModifier: 0, positiveFactors, negativeFactors, allHardFactors: hardFactors, diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index dd42f97..72e5cb0 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -223,7 +223,7 @@ export default function Compare() { {/* Desktop table */} - + {/* AI Summary */} = 2} /> @@ -263,9 +263,11 @@ export default function Compare() { {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 => - [...item.match.positiveFactors, ...item.match.negativeFactors] - .find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) + allCriteria(item).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) @@ -278,11 +280,6 @@ export default function Compare() { {label} - - {[1, 2, 3, 4, 5].map(i => ( - - ))} - {factors.map((factor, idx) => ( diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 94e7e20..5375cf4 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -233,11 +233,6 @@ export default function MatchDetail() { > Zurück zu Resultaten - {isFuture && ( )} diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index a5a0232..f04e6b1 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -68,7 +68,7 @@ export default function Results() { 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 => { if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties @@ -123,7 +123,7 @@ export default function Results() { /> )} - + {activeNeed && ( diff --git a/src/services/aiService.ts b/src/services/aiService.ts index a777cbd..181c163 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -53,9 +53,24 @@ export interface ComparisonSummary { biggestTradeoffs: string[] missingDataWarnings: 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 { + 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) { return { strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' }, @@ -64,14 +79,12 @@ function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary biggestTradeoffs: [], missingDataWarnings: [], 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 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' + // 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 { strongestOption: { matchId: strongest.matchId, @@ -116,6 +181,9 @@ function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary biggestTradeoffs: tradeoffs, missingDataWarnings: missingWarnings, recommendedNextStep: topNextAction, + overallAssessment, + perPropertyAssessment, + recommendation, } }