diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx
index 83b5ac3..06a7abb 100644
--- a/src/components/match-card/IntelligenceMatchCard.tsx
+++ b/src/components/match-card/IntelligenceMatchCard.tsx
@@ -64,11 +64,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
}}
>
- {vm.matchScore}
-
-
- {theme.label}
+ {vm.matchScore}%
+ {tier !== 'bronze' && (
+
+ {theme.label}
+
+ )}
{/* Result type chip — overlaid top-right */}
@@ -123,6 +125,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
>
)}
+
{/* Actions */}
{vm.actions.map(a => (
diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts
index 9a79e15..a8a667a 100644
--- a/src/components/match-card/MatchCardViewModel.ts
+++ b/src/components/match-card/MatchCardViewModel.ts
@@ -47,6 +47,7 @@ export interface MatchCardViewModel {
actions: MatchCardAction[]
disclaimer?: string // required for FUTURE_AVAILABILITY
explainabilitySummary?: string
+ taxCalculatorUrl?: string // deeplink to cantonal tax calculator
// States
isSelected?: boolean
diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx
index 161b0a7..c4429de 100644
--- a/src/components/match-detail/ScoreBreakdownPanel.tsx
+++ b/src/components/match-detail/ScoreBreakdownPanel.tsx
@@ -1,4 +1,5 @@
-import { Box, Divider, LinearProgress, Paper, Typography } from '@mui/material'
+import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
+import { ExternalLink } from 'lucide-react'
import type { Match } from '../../domain/match'
interface BreakdownRowProps {
@@ -41,9 +42,10 @@ function BreakdownRow({ label, value, max, description, color = 'primary', modif
interface Props {
match: Match
+ taxCalculatorUrl?: string
}
-export function ScoreBreakdownPanel({ match }: Props) {
+export function ScoreBreakdownPanel({ match, taxCalculatorUrl }: Props) {
const sb = match.scoreBreakdown
const hardColor: 'success' | 'warning' | 'error' =
@@ -66,10 +68,30 @@ export function ScoreBreakdownPanel({ match }: Props) {
label="Soft Factors (40%)"
value={sb.softFactorScore}
max={100}
- description="Prestige, Erreichbarkeit, ESG, Flexibilität"
+ description="Prestige, Erreichbarkeit, ESG, Steuerlast"
color={softColor}
/>
+ {taxCalculatorUrl && (
+
+
+
+
+ KI hat Steuerlast bewertet
+
+
+ Steuerrechner Gemeinde öffnen →
+
+
+
+ )}
+
- {results.map(result => (
-
- ))}
-
- )
- }
+const TIER_CONFIG = [
+ {
+ key: 'gold' as const,
+ label: 'Top Matches',
+ sublabel: '90–100 Punkte',
+ filter: (r: UnifiedMatchResult) => r.matchScore >= 90,
+ },
+ {
+ key: 'silver' as const,
+ label: 'Starke Matches',
+ sublabel: '80–89 Punkte',
+ filter: (r: UnifiedMatchResult) => r.matchScore >= 80 && r.matchScore < 90,
+ },
+ {
+ key: 'bronze' as const,
+ label: 'Weitere Treffer',
+ sublabel: 'unter 80 Punkte',
+ filter: (r: UnifiedMatchResult) => r.matchScore < 80,
+ },
+]
+interface TierHeaderProps {
+ label: string
+ sublabel: string
+ tierKey: 'gold' | 'silver' | 'bronze'
+ count: number
+ isFirst: boolean
+}
+
+function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
+ const theme = SCORE_THEME[tierKey]
return (
- <>
- {results.map(result => (
-
- ))}
- >
+
+
+
+
+ {label}
+
+ {sublabel}
+
+
+
+
+ )
+}
+
+export function UnifiedResultFeed({ results, view = 'list' }: Props) {
+ const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
+ .filter(t => t.items.length > 0)
+
+ return (
+
+ {tiers.map((tier, idx) => (
+
+
+ {view === 'grid' ? (
+
+ {tier.items.map(result => (
+
+ ))}
+
+ ) : (
+
+ {tier.items.map(result => (
+
+ ))}
+
+ )}
+
+ ))}
+
)
}
diff --git a/src/components/shared/scoreTheme.ts b/src/components/shared/scoreTheme.ts
index 3d572f2..35c7886 100644
--- a/src/components/shared/scoreTheme.ts
+++ b/src/components/shared/scoreTheme.ts
@@ -2,7 +2,7 @@ export type ScoreTier = 'gold' | 'silver' | 'bronze'
export function getScoreTier(score: number): ScoreTier {
if (score >= 90) return 'gold'
- if (score >= 75) return 'silver'
+ if (score >= 80) return 'silver'
return 'bronze'
}
diff --git a/src/domain/need.ts b/src/domain/need.ts
index 9ffcbab..7f5553d 100644
--- a/src/domain/need.ts
+++ b/src/domain/need.ts
@@ -27,7 +27,7 @@ export interface WeightingProfile {
prestige: number
accessibility: number
expansionPotential: number
- flexibility: number
+ taxEnvironment: number
[key: string]: number
}
diff --git a/src/domain/needBuilder.ts b/src/domain/needBuilder.ts
index 07a5ddd..11db396 100644
--- a/src/domain/needBuilder.ts
+++ b/src/domain/needBuilder.ts
@@ -54,7 +54,7 @@ export const NeedBuilderStep = {
} as const
export type NeedBuilderStep = typeof NeedBuilderStep[keyof typeof NeedBuilderStep]
-export const WEIGHTING_KEYS = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility'] as const
+export const WEIGHTING_KEYS = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'taxEnvironment'] as const
export type WeightingKey = typeof WEIGHTING_KEYS[number]
export const WEIGHTING_LABELS: Record = {
@@ -65,5 +65,5 @@ export const WEIGHTING_LABELS: Record = {
prestige: 'Prestige',
accessibility: 'Erreichbarkeit',
expansionPotential: 'Expansionspotenzial',
- flexibility: 'Flexibilität',
+ taxEnvironment: 'Steuerlast',
}
diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts
index 84d9b31..0bcbfbf 100644
--- a/src/features/matching/matchCardAdapter.ts
+++ b/src/features/matching/matchCardAdapter.ts
@@ -10,6 +10,7 @@ import type {
MatchCardAction,
MatchCardReason,
} from '../../components/match-card/MatchCardViewModel'
+import { getCityIntelligence } from '../../lib/locationIntelligence'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -76,6 +77,9 @@ export function buildMatchCardViewModel(
const reasons = buildReasons(match.positiveFactors)
+ const cityIntel = city ? getCityIntelligence(city) : null
+ const taxCalculatorUrl = cityIntel?.taxCalculatorUrl
+
const dataQualityScore =
property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
@@ -104,6 +108,7 @@ export function buildMatchCardViewModel(
? (signal?.disclaimer ?? 'Probabilistisches Signal – kein bestätigtes Objekt')
: undefined,
explainabilitySummary: match.explainabilitySummary,
+ taxCalculatorUrl,
isReviewRequired: match.status === 'PENDING_REVIEW',
isStaleData: false,
}
diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts
index 3699dac..32f208f 100644
--- a/src/features/matching/scoreCalculator.ts
+++ b/src/features/matching/scoreCalculator.ts
@@ -22,7 +22,7 @@ function resolveProfile(need: Need, property: Property): ScoringWeightProfile {
if (!np) return base
// Apply need's custom core weights, then renormalize the full profile to 1.00
- const CORE = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility']
+ const CORE = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'taxEnvironment']
for (const key of CORE) {
if (typeof np[key] === 'number') base[key] = np[key]
}
diff --git a/src/lib/locationIntelligence.ts b/src/lib/locationIntelligence.ts
index 947fe1d..3e97333 100644
--- a/src/lib/locationIntelligence.ts
+++ b/src/lib/locationIntelligence.ts
@@ -12,6 +12,7 @@ export interface CityIntelligence {
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
+ taxCalculatorUrl?: string // Kantonaler Steuerrechner
}
export const CITY_INTELLIGENCE: Record = {
@@ -30,6 +31,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 38,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 100,
+ taxCalculatorUrl: 'https://www.zh.ch/de/steuern-finanzen/steuerrechner.html',
},
'Basel': {
vacancyRatePct: 4.1,
@@ -46,6 +48,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 52,
demandStrength: 'HIGH',
taxIndexCanton: 98,
+ taxCalculatorUrl: 'https://www.steuern.bs.ch/steuerrechner',
},
'Bern': {
vacancyRatePct: 3.5,
@@ -61,6 +64,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 61,
demandStrength: 'MEDIUM',
taxIndexCanton: 112,
+ taxCalculatorUrl: 'https://www.taxme.ch',
},
'Zug': {
vacancyRatePct: 1.9,
@@ -76,6 +80,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 24,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 60,
+ taxCalculatorUrl: 'https://www.zg.ch/behoerden/finanzdirektion/steuerverwaltung/steuerrechner',
},
'Winterthur': {
vacancyRatePct: 5.8,
@@ -91,6 +96,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 74,
demandStrength: 'MEDIUM',
taxIndexCanton: 119,
+ taxCalculatorUrl: 'https://www.zh.ch/de/steuern-finanzen/steuerrechner.html',
},
'Geneva': {
vacancyRatePct: 2.2,
@@ -106,6 +112,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 31,
demandStrength: 'HIGH',
taxIndexCanton: 125,
+ taxCalculatorUrl: 'https://www.ge.ch/calculer-impots',
},
'St.Gallen': {
vacancyRatePct: 6.2,
@@ -119,6 +126,7 @@ export const CITY_INTELLIGENCE: Record = {
avgDaysOnMarket: 88,
demandStrength: 'LOW',
taxIndexCanton: 107,
+ taxCalculatorUrl: 'https://www.steuern.sg.ch/home/steuerrechner.html',
},
}
diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts
index dc59e74..7a772ee 100644
--- a/src/mock-data/properties.ts
+++ b/src/mock-data/properties.ts
@@ -397,6 +397,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 60,
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
+ images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2024-12-01T08:00:00Z',
updatedAt: '2025-05-04T09:00:00Z',
@@ -436,6 +437,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 2,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
createdAt: '2025-02-15T14:00:00Z',
updatedAt: '2025-04-10T09:00:00Z',
},
@@ -463,6 +465,7 @@ export const mockProperties: Property[] = [
warnings: ['Daten aus Drittquelle – nicht verifiziert'],
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
createdAt: '2025-03-01T10:00:00Z',
updatedAt: '2025-03-20T15:00:00Z',
},
@@ -496,6 +499,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 5,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
createdAt: '2025-03-12T11:00:00Z',
updatedAt: '2025-04-05T10:00:00Z',
},
@@ -529,6 +533,7 @@ export const mockProperties: Property[] = [
parkingSpots: 35,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
createdAt: '2025-02-20T09:00:00Z',
updatedAt: '2025-03-28T12:00:00Z',
},
@@ -563,6 +568,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 3,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
createdAt: '2025-03-05T13:00:00Z',
updatedAt: '2025-04-18T11:00:00Z',
},
@@ -596,6 +602,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 8,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1481277542470-605612bd2d61?w=800&h=400&fit=crop'],
createdAt: '2025-02-28T10:00:00Z',
updatedAt: '2025-04-02T09:00:00Z',
},
@@ -624,6 +631,7 @@ export const mockProperties: Property[] = [
warnings: ['Hallenhöhe nicht verifiziert', 'Kranbahn Status unklar'],
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&h=400&fit=crop'],
createdAt: '2025-03-10T08:00:00Z',
updatedAt: '2025-03-25T14:00:00Z',
},
@@ -657,6 +665,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 9,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
createdAt: '2025-03-18T09:00:00Z',
updatedAt: '2025-04-12T11:00:00Z',
},
@@ -691,6 +700,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 3,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
createdAt: '2025-02-10T10:00:00Z',
updatedAt: '2025-04-08T09:00:00Z',
},
@@ -724,6 +734,7 @@ export const mockProperties: Property[] = [
publicTransportMinutes: 6,
},
riskLevel: RiskLevel.MEDIUM,
+ images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
createdAt: '2025-03-08T08:00:00Z',
updatedAt: '2025-04-14T10:00:00Z',
},
diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx
index 724234a..f670a60 100644
--- a/src/pages/demand/Compare.tsx
+++ b/src/pages/demand/Compare.tsx
@@ -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 = {
const RISK_LEVEL_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
+const CRITERION_ALIASES: Record = {
+ 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 (
@@ -171,6 +222,95 @@ export default function Compare() {
{/* AI Summary */}
= 2} />
+ {/* Criteria Head-to-Head */}
+ {activeNeed && relevantCriteria.length > 0 && (
+
+
+
+
+ Vergleich nach Suchkriterien
+ Basierend auf der Suche: {activeNeed.companyName}
+
+ {overallWinnerIdx !== -1 && (
+
+
+
+ Gesamtsieger
+ {getTitle(compareItems[overallWinnerIdx])}
+
+
+ )}
+
+
+
+
+
+ Kriterium
+ {compareItems.map(item => (
+
+
+ {getTitle(item)}
+
+
+ ))}
+
+
+
+ {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 (
+
+
+ {label}
+
+ {[1, 2, 3, 4, 5].map(i => (
+
+ ))}
+
+
+ {factors.map((factor, idx) => (
+
+ {factor ? (
+
+
+ {scoreBar(factor.score / 100)}
+ {idx === winnerIdx && (
+ }
+ sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
+ '& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
+ '& .MuiChip-label': { px: 0.75 } }} />
+ )}
+
+
+ {factor.explanation}
+
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )
+ })}
+
+
+
+
+ )}
+
diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx
index a43f437..6e1c360 100644
--- a/src/pages/demand/MatchDetail.tsx
+++ b/src/pages/demand/MatchDetail.tsx
@@ -1,4 +1,5 @@
-import { Box, CircularProgress, Paper, Typography } from '@mui/material'
+import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
+import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -9,12 +10,12 @@ import { useCompareStore } from '../../stores/compareStore'
import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
-import { LocationPreview, PropertyMap } from '../../components/shared'
+import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
+import { PropertyMap } from '../../components/shared'
+import { getCityIntelligence } from '../../lib/locationIntelligence'
import {
LocationIntelligencePanel,
- MatchDetailHeader,
ExecutiveSummaryPanel,
- PropertyOverviewPanel,
NeedAlignmentPanel,
ScoreBreakdownPanel,
TradeoffPanel,
@@ -28,6 +29,12 @@ import type { MatchCardReason } from '../../components/match-card/MatchCardViewM
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
+const RESULT_TYPE_META: Record = {
+ VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
+ EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
+ FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
+}
+
function buildReasons(match: NonNullable['data']>): MatchCardReason[] {
return match.positiveFactors.slice(0, 3).map(f => ({
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
@@ -37,6 +44,13 @@ function buildReasons(match: NonNullable['data
}))
}
+function confColor(c: number) {
+ return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
+}
+function dqColor(q: number) {
+ return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
+}
+
export default function MatchDetail() {
const { matchId } = useParams<{ matchId: string }>()
const navigate = useNavigate()
@@ -44,10 +58,8 @@ export default function MatchDetail() {
const { openAddDialog } = useShortlistStore()
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
-
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
-
const { data: property = null } = useQuery({
queryKey: ['property', match?.propertyId],
queryFn: () => propertyService.getById(match!.propertyId),
@@ -82,47 +94,49 @@ export default function MatchDetail() {
Match nicht gefunden
-
- Das gesuchte Match existiert nicht oder wurde entfernt.
-
)
}
const reasons = buildReasons(match)
+ const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '–', color: '#64748b' }
+
+ const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–'
+ const city = property?.location?.city
+ const location = city
+ ? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
+ : signal?.locationHint ?? '–'
+ const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
+ const confPct = Math.round(match.confidenceLevel * 100)
+ const dqPct = Math.round(dqScore * 100)
+
+ // Tax calculator — only shown when AI evaluated taxEnvironment
+ const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
+ const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
+ const cityIntel = city ? getCityIntelligence(city) : null
+ const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const handleCompare = () => {
if (match && !isFuture && property) {
addToCompare({
resultType: property.resultType === 'EXTERNAL_MARKET' ? 'EXTERNAL_MARKET' : 'VERIFIED_PORTFOLIO',
- matchId: match.id,
- needId: match.needId,
- matchScore: match.matchScore,
- match,
- property,
+ matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
})
} else if (match && isFuture && signal) {
addToCompare({
resultType: 'FUTURE_AVAILABILITY',
- matchId: match.id,
- needId: match.needId,
- matchScore: match.matchScore,
- match,
- signal,
+ matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, signal,
})
}
navigate('/demand/compare')
}
- const handleBack = () => navigate(-1)
-
const handleShortlist = () => {
- const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id
openAddDialog({
resultId: match.id,
resultType: match.resultType ?? 'VERIFIED_PORTFOLIO',
- title,
+ title: property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id,
matchScore: match.matchScore,
confidenceScore: match.confidenceLevel,
sourceLabel: property?.sourceLabel ?? match.resultType ?? 'VERIFIED_PORTFOLIO',
@@ -131,52 +145,154 @@ export default function MatchDetail() {
})
}
- return (
-
-
-
+ // Key facts for the strip below the hero
+ const keyFacts = isFuture ? [
+ { label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²` : '–' },
+ { label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '–' },
+ { label: 'Wahrscheinlichkeit', value: signal?.probability ? `${Math.round(signal.probability * 100)}%` : '–' },
+ ] : [
+ { label: 'Nutzfläche', value: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : '–' },
+ { label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '–' },
+ { label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
+ { label: 'Nutzungsart', value: property?.assetType ?? '–' },
+ ]
- {/* Photo + Map */}
- {!isFuture && property && (
-
- {property.images?.[0] && (
-
+
+
+ {/* Sticky back nav */}
+
+ }
+ onClick={() => navigate(-1)}
+ size="small"
+ sx={{ color: '#64748b', fontWeight: 500, textTransform: 'none' }}
+ >
+ Zurück zu Resultaten
+
+
+ {isFuture && (
+
+ )}
+
+
+ {/* Hero: image first, map fallback */}
+ {!isFuture && (
+ property?.images?.[0] ? (
+
+
- )}
- {property.location.coordinates && (
-
- )}
-
+
+ ) : property?.location?.coordinates ? (
+
+ ) : null
)}
-
+ {/* Property header — white section below hero */}
+
+
+
+ {/* Left: title + address + chips */}
+
+
+ {title}
+
+
+ {location}
+
+
+
+ {property?.assetType && (
+
+ )}
+
+
+
+
+
+ {/* Right: score + actions */}
+
+
+
+ }
+ onClick={handleShortlist}
+ sx={{ textTransform: 'none' }}
+ >
+ Shortlist
+
+ }
+ onClick={handleCompare}
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
+ >
+ Vergleichen
+
+
+
+
+
+
+ {/* Key facts strip */}
+
+
+ {keyFacts.map((fact, i) => (
+
+
+ {fact.label}
+
+
+ {fact.value}
+
+
+ ))}
+
+
+
+ {/* Main content */}
+
+
{/* Main column */}
-
+
-
- {/* Why It Matches — structured from scoreFactors */}
{reasons.length > 0 && (
-
+
Warum dieses Match
{match.negativeFactors.length > 0 && (
@@ -197,6 +313,25 @@ export default function MatchDetail() {
)}
+
+ {/* Map — always show when image was the hero above */}
+ {!isFuture && property?.images?.[0] && property?.location?.coordinates && (
+
+
+ Standort
+
+ {property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
+
+
+
+
+ )}
+
@@ -204,9 +339,9 @@ export default function MatchDetail() {
{isFuture && }
- {/* Sidebar — sticky */}
-
-
+ {/* Sidebar */}
+
+