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 */} + + + + {isFuture && ( + + )} + + + {/* Hero: image first, map fallback */} + {!isFuture && ( + property?.images?.[0] ? ( + + {property.title} - )} - {property.location.coordinates && ( - - )} - + + ) : property?.location?.coordinates ? ( + + ) : null )} - + {/* Property header — white section below hero */} + + + + {/* Left: title + address + chips */} + + + {title} + + + {location} + + + + {property?.assetType && ( + + )} + + + + + + {/* Right: score + actions */} + + + + + + + + + + + {/* 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 */} + +