feat: Steuerlast-Kriterium, ImmoScout-Layout, Gold/Silver/Bronze-Trennung
- Flächensuche: Flexibilität durch Steuerlast (taxEnvironment) ersetzt - MatchDetail: Bild als Hero-Banner oben, Karte eingebettet im Inhalt darunter - Ergebnisliste: Gold/Silber/Bronze-Abschnitte mit farbigen Trennlinien - ScoreBreakdown: KI-Steuerlast-Link zur kantonalen Steuerrechner-Seite - Beispieldaten: alle Objekte mit passenden Bildern versehen - locationIntelligence: taxCalculatorUrl pro Kanton ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,11 +64,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
||||
{vm.matchScore}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
||||
{theme.label}
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
{tier !== 'bronze' && (
|
||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
||||
{theme.label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Result type chip — overlaid top-right */}
|
||||
@@ -123,6 +125,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
{vm.actions.map(a => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
|
||||
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
|
||||
KI hat Steuerlast bewertet
|
||||
</Typography>
|
||||
<Link
|
||||
href={taxCalculatorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
|
||||
>
|
||||
Steuerrechner Gemeinde öffnen →
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<BreakdownRow
|
||||
|
||||
@@ -1,28 +1,107 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||
import { SCORE_THEME } from '../shared/scoreTheme'
|
||||
|
||||
interface Props {
|
||||
results: UnifiedMatchResult[]
|
||||
view?: 'list' | 'grid'
|
||||
}
|
||||
|
||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
if (view === 'grid') {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{results.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
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 => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 28,
|
||||
borderRadius: 2,
|
||||
background: theme.gradient,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: theme.text === '#7a4f00' ? '#92400e' : theme.text === '#334155' ? '#1e293b' : '#7c3d12' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sublabel}</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={count}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
background: theme.gradient,
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}
|
||||
/>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
{tiers.map((tier, idx) => (
|
||||
<Box key={tier.key}>
|
||||
<TierHeader
|
||||
label={tier.label}
|
||||
sublabel={tier.sublabel}
|
||||
tierKey={tier.key}
|
||||
count={tier.items.length}
|
||||
isFirst={idx === 0}
|
||||
/>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export interface WeightingProfile {
|
||||
prestige: number
|
||||
accessibility: number
|
||||
expansionPotential: number
|
||||
flexibility: number
|
||||
taxEnvironment: number
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WeightingKey, string> = {
|
||||
@@ -65,5 +65,5 @@ export const WEIGHTING_LABELS: Record<WeightingKey, string> = {
|
||||
prestige: 'Prestige',
|
||||
accessibility: 'Erreichbarkeit',
|
||||
expansionPotential: 'Expansionspotenzial',
|
||||
flexibility: 'Flexibilität',
|
||||
taxEnvironment: 'Steuerlast',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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<string, CityIntelligence> = {
|
||||
@@ -30,6 +31,7 @@ export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
|
||||
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<string, CityIntelligence> = {
|
||||
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<string, CityIntelligence> = {
|
||||
avgDaysOnMarket: 61,
|
||||
demandStrength: 'MEDIUM',
|
||||
taxIndexCanton: 112,
|
||||
taxCalculatorUrl: 'https://www.taxme.ch',
|
||||
},
|
||||
'Zug': {
|
||||
vacancyRatePct: 1.9,
|
||||
@@ -76,6 +80,7 @@ export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
|
||||
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<string, CityIntelligence> = {
|
||||
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<string, CityIntelligence> = {
|
||||
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<string, CityIntelligence> = {
|
||||
avgDaysOnMarket: 88,
|
||||
demandStrength: 'LOW',
|
||||
taxIndexCanton: 107,
|
||||
taxCalculatorUrl: 'https://www.steuern.sg.ch/home/steuerrechner.html',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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<string, { label: string; color: string }> = {
|
||||
|
||||
const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
|
||||
const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
||||
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 (
|
||||
<Box>
|
||||
@@ -171,6 +222,95 @@ export default function Compare() {
|
||||
{/* AI Summary */}
|
||||
<AICompareSummary summary={aiSummary} isLoading={aiLoading && compareItems.length >= 2} />
|
||||
|
||||
{/* Criteria Head-to-Head */}
|
||||
{activeNeed && relevantCriteria.length > 0 && (
|
||||
<Card sx={{ mb: 3, overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Trophy size={18} color="#d4920e" />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
|
||||
</Box>
|
||||
{overallWinnerIdx !== -1 && (
|
||||
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Trophy size={16} color="#d4920e" />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table sx={{ tableLayout: 'auto' }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
||||
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
||||
{compareItems.map(item => (
|
||||
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
|
||||
{getTitle(item)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell sx={LABEL_SX}>
|
||||
<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>
|
||||
{factors.map((factor, idx) => (
|
||||
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
||||
{factor ? (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{scoreBar(factor.score / 100)}
|
||||
{idx === winnerIdx && (
|
||||
<Chip label="Besser" size="small"
|
||||
icon={<CheckCircle2 size={10} />}
|
||||
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
|
||||
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
|
||||
'& .MuiChip-label': { px: 0.75 } }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary"
|
||||
sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', lineHeight: 1.4 }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<MissingDataCell reason="Kein Score für dieses Kriterium" />
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card sx={{ overflowX: 'auto' }}>
|
||||
<Table sx={{ tableLayout: 'auto' }}>
|
||||
|
||||
|
||||
@@ -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<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['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<ReturnType<typeof useMatchDetail>['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() {
|
||||
<Box sx={{ px: 3, py: 4 }}>
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">Match nicht gefunden</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Das gesuchte Match existiert nicht oder wurde entfernt.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ px: 3, py: 2 }}>
|
||||
<AddToShortlistDialog />
|
||||
<MatchDetailHeader
|
||||
match={match}
|
||||
property={property}
|
||||
signal={signal}
|
||||
onBack={handleBack}
|
||||
onCompare={handleCompare}
|
||||
onShortlist={handleShortlist}
|
||||
/>
|
||||
// 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 && (
|
||||
<Box sx={{ mb: 2, borderRadius: 2, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
|
||||
{property.images?.[0] && (
|
||||
<LocationPreview
|
||||
imageUrl={property.images[0]}
|
||||
lat={property.location.coordinates?.lat}
|
||||
lng={property.location.coordinates?.lng}
|
||||
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
|
||||
cityLabel={property.location.city}
|
||||
height={200}
|
||||
return (
|
||||
<Box sx={{ bgcolor: '#f1f5f9', minHeight: '100vh' }}>
|
||||
<AddToShortlistDialog />
|
||||
|
||||
{/* Sticky back nav */}
|
||||
<Box sx={{
|
||||
px: 3, py: 1.25, bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
}}>
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={15} />}
|
||||
onClick={() => navigate(-1)}
|
||||
size="small"
|
||||
sx={{ color: '#64748b', fontWeight: 500, textTransform: 'none' }}
|
||||
>
|
||||
Zurück zu Resultaten
|
||||
</Button>
|
||||
<Chip
|
||||
label={`Match ${match.id.slice(-3).toUpperCase()}`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 700, letterSpacing: 0.5 }}
|
||||
/>
|
||||
{isFuture && (
|
||||
<Chip label="Probabilistisches Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Hero: image first, map fallback */}
|
||||
{!isFuture && (
|
||||
property?.images?.[0] ? (
|
||||
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e2e8f0', flexShrink: 0 }}>
|
||||
<img
|
||||
src={property.images[0]}
|
||||
alt={property.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{property.location.coordinates && (
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={280}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : property?.location?.coordinates ? (
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={340}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* Property header — white section below hero */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ px: 3, pt: 2.5, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
{/* Left: title + address + chips */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25 }}>
|
||||
{location}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
|
||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: '0.72rem' }} />
|
||||
{property?.assetType && (
|
||||
<Chip label={property.assetType} size="small" variant="outlined" sx={{ fontSize: '0.72rem' }} />
|
||||
)}
|
||||
<Chip
|
||||
label={`${confPct}% Konfidenz`}
|
||||
size="small"
|
||||
sx={{ bgcolor: confColor(match.confidenceLevel), color: 'white', fontSize: '0.72rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`DQ ${dqPct}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: dqColor(dqScore), color: 'white', fontSize: '0.72rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right: score + actions */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 1.5, flexShrink: 0 }}>
|
||||
<MatchScoreDisplay score={match.matchScore} size="lg" />
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Bookmark size={14} />}
|
||||
onClick={handleShortlist}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Shortlist
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Columns2 size={14} />}
|
||||
onClick={handleCompare}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||
>
|
||||
Vergleichen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key facts strip */}
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', px: 3, py: 1.5, gap: 0 }}>
|
||||
{keyFacts.map((fact, i) => (
|
||||
<Box key={fact.label} sx={{
|
||||
flex: 1,
|
||||
pl: i === 0 ? 0 : 2,
|
||||
pr: 2,
|
||||
borderLeft: i === 0 ? 'none' : '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>
|
||||
{fact.label}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, mt: 0.25 }}>
|
||||
{fact.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box sx={{ px: 3, py: 3, display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
|
||||
{/* Main column */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<ExecutiveSummaryPanel match={match} />
|
||||
<PropertyOverviewPanel match={match} property={property} signal={signal} />
|
||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||
|
||||
{/* Why It Matches — structured from scoreFactors */}
|
||||
{reasons.length > 0 && (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
|
||||
<MatchReasonList reasons={reasons} maxItems={3} />
|
||||
{match.negativeFactors.length > 0 && (
|
||||
@@ -197,6 +313,25 @@ export default function MatchDetail() {
|
||||
)}
|
||||
|
||||
<LocationIntelligencePanel property={property} />
|
||||
|
||||
{/* Map — always show when image was the hero above */}
|
||||
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={220}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<TradeoffPanel match={match} />
|
||||
<RiskPanel match={match} />
|
||||
<MissingInformationPanel match={match} />
|
||||
@@ -204,9 +339,9 @@ export default function MatchDetail() {
|
||||
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
||||
</Box>
|
||||
|
||||
{/* Sidebar — sticky */}
|
||||
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 24 }}>
|
||||
<ScoreBreakdownPanel match={match} />
|
||||
{/* Sidebar */}
|
||||
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} />
|
||||
<NextActionsPanel
|
||||
match={match}
|
||||
onCompare={handleCompare}
|
||||
|
||||
Reference in New Issue
Block a user