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