feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore exclusively — allFactors always populated, no more dual-system (computeScore removed), weights from weightingProfile reflected in every breakdown - ScoreInlineBreakdown: new component shows hard/soft criteria with importance labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards - MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel - MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability' - aiService budget parser: values < 100 treated as monthly and multiplied by 12 to produce correct annual CHF/m²/year value — fixes 0-result searches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
|
||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
@@ -82,6 +83,13 @@ export function MatchCardCompact({ vm }: Props) {
|
||||
|
||||
<Divider sx={{ mb: 1.25 }} />
|
||||
|
||||
{/* Score formula — always visible */}
|
||||
{vm.scoreBreakdown && (
|
||||
<Box sx={{ mb: 1.25 }}>
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Top reason only */}
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
|
||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
@@ -59,6 +60,18 @@ export function MatchCardExpanded({ vm }: Props) {
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Score breakdown — full criteria with weights */}
|
||||
{vm.scoreBreakdown && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, color: '#1e293b' }}>
|
||||
Bewertungsherleitung
|
||||
</Typography>
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Why it matches — all 3 reasons */}
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResultType } from '../../domain/enums'
|
||||
import type { TradeOff, Risk, MissingDataItem } from '../../domain/match'
|
||||
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
|
||||
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
||||
@@ -76,6 +76,14 @@ export interface MatchCardViewModel {
|
||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||
|
||||
// Score transparency
|
||||
scoreBreakdown?: {
|
||||
hardMatchScore: number
|
||||
softFactorScore: number
|
||||
totalScore: number
|
||||
}
|
||||
allFactors?: ScoreFactor[]
|
||||
|
||||
// States
|
||||
isSelected?: boolean
|
||||
isCompareSelected?: boolean
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
|
||||
import type { ScoreFactor } from '../../domain/match'
|
||||
|
||||
interface ScoreBreakdownData {
|
||||
hardMatchScore: number
|
||||
softFactorScore: number
|
||||
totalScore: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scoreBreakdown: ScoreBreakdownData
|
||||
allFactors?: ScoreFactor[]
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const CRITERION_LABEL: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
|
||||
talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld',
|
||||
}
|
||||
|
||||
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
||||
}
|
||||
|
||||
function scoreTextColor(v: number): string {
|
||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
||||
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
||||
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
||||
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
||||
return { label: 'Unwichtig', color: '#cbd5e1' }
|
||||
}
|
||||
|
||||
function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
||||
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
|
||||
const pct = Math.round(factor.weight * 100)
|
||||
const imp = importanceLabel(factor.weight, maxWeight)
|
||||
const color = scoreColor(factor.score)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.35 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 85 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>{imp.label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
||||
{factor.score}/100
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
|
||||
{factor.contribution.toFixed(1)} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={factor.score} color={color} sx={{ height: 4, borderRadius: 3, mb: 0.3 }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3, fontSize: '0.7rem' }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact = false }: Props) {
|
||||
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
|
||||
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
|
||||
|
||||
const formulaBox = (
|
||||
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.25, fontWeight: 500, fontSize: '0.68rem' }}>
|
||||
Berechnung
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
|
||||
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
||||
{' = '}{hardContrib} + {softContrib}{' = '}
|
||||
<span style={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Compact: only the formula line
|
||||
if (compact || !allFactors || allFactors.length === 0) {
|
||||
return formulaBox
|
||||
}
|
||||
|
||||
// Full: all criteria + formula
|
||||
const hardFactors = allFactors.filter(f => HARD_KEYS.has(f.criterion))
|
||||
const softFactors = allFactors.filter(f => !HARD_KEYS.has(f.criterion))
|
||||
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
|
||||
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||||
Hart-Kriterien
|
||||
</Typography>
|
||||
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
||||
{sb.hardMatchScore}/100 × 60%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
|
||||
</Box>
|
||||
|
||||
{softFactors.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||||
Soft-Faktoren
|
||||
</Typography>
|
||||
{softFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1, mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
||||
{sb.softFactorScore}/100 × 40%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>{softContrib} Pkt</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{formulaBox}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, ShieldCheck, X } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { Match, ScoreFactor } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -9,52 +10,208 @@ function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
||||
}
|
||||
|
||||
function scoreTextColor(v: number): string {
|
||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
const CREDIBILITY_LABELS: Record<string, string> = {
|
||||
HIGH: 'Hohe Quellenqualität',
|
||||
MEDIUM: 'Mittlere Quellenqualität',
|
||||
LOW: 'Niedrige Quellenqualität',
|
||||
}
|
||||
|
||||
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||
|
||||
interface BreakdownRowProps {
|
||||
label: string
|
||||
value: number
|
||||
max: number
|
||||
description: string
|
||||
color?: 'success' | 'warning' | 'error' | 'primary'
|
||||
modifier?: boolean
|
||||
const CRITERION_LABEL: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
|
||||
talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld',
|
||||
}
|
||||
|
||||
function BreakdownRow({ label, value, max, description, color = 'primary', modifier }: BreakdownRowProps) {
|
||||
const pct = Math.round((Math.abs(value) / max) * 100)
|
||||
const isNegative = modifier && value < 0
|
||||
const isPositive = modifier && value > 0
|
||||
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
function factorLabel(criterion: string): string {
|
||||
return CRITERION_LABEL[criterion] ?? criterion
|
||||
}
|
||||
|
||||
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
|
||||
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
||||
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
||||
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
||||
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
||||
return { label: 'Unwichtig', color: '#cbd5e1' }
|
||||
}
|
||||
|
||||
// ── Single criterion row ───────────────────────────────────────────────────────
|
||||
|
||||
function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
||||
const label = factorLabel(factor.criterion)
|
||||
const pct = Math.round(factor.weight * 100)
|
||||
const color = scoreColor(factor.score)
|
||||
const imp = importanceLabel(factor.weight, maxWeight)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{label}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: isNegative ? '#c0392b' : isPositive ? '#1a7a4a' : 'text.primary' }}
|
||||
>
|
||||
{modifier && value > 0 ? '+' : ''}{modifier ? value : `${value}/${max}`}
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
|
||||
{imp.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
{!modifier && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
||||
{factor.score}/100
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
||||
{factor.contribution.toFixed(1)} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(pct, 100)}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 0.5 }}
|
||||
value={factor.score}
|
||||
color={color}
|
||||
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">{description}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||
|
||||
interface StandardBreakdownProps {
|
||||
match: Match
|
||||
taxCalculatorUrl?: string
|
||||
}
|
||||
|
||||
function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {
|
||||
const sb = match.scoreBreakdown
|
||||
|
||||
// Use allFactors when available (typed English keys); fall back to pos + neg
|
||||
const hasAllFactors = !!match.allFactors
|
||||
const allFactors = match.allFactors ?? [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
||||
|
||||
const hardFactors = hasAllFactors
|
||||
? allFactors.filter(f => HARD_KEYS.has(f.criterion))
|
||||
: allFactors // static mock data: show flat, no grouping
|
||||
|
||||
const softFactors = hasAllFactors
|
||||
? allFactors.filter(f => !HARD_KEYS.has(f.criterion))
|
||||
: []
|
||||
|
||||
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
|
||||
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
|
||||
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
|
||||
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hard criteria */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
||||
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
|
||||
</Typography>
|
||||
|
||||
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
||||
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>
|
||||
{hardContrib} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Soft factors: full list when allFactors present, otherwise summary row */}
|
||||
{!hasAllFactors && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
||||
Soft-Faktoren {sb.softFactorScore}/100 × 40%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
|
||||
{softContrib} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasAllFactors && softFactors.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
||||
Soft-Faktoren
|
||||
</Typography>
|
||||
{softFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
||||
Soft-Score {sb.softFactorScore}/100 × 40%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
|
||||
{softContrib} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tax link */}
|
||||
{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 }}>
|
||||
Steuerlast in Bewertung eingeflossen
|
||||
</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={{ mb: 1.5 }} />
|
||||
|
||||
{/* Formula row — always visible */}
|
||||
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.4, fontWeight: 500 }}>
|
||||
Berechnung
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
|
||||
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
||||
{' = '}{hardContrib} + {softContrib} = {sb.totalScore}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Total */}
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
||||
bgcolor: sb.totalScore >= 78 ? '#f0fdf4' : sb.totalScore >= 52 ? '#fffbeb' : '#fef2f2',
|
||||
p: 1.5, borderRadius: 1,
|
||||
}}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
|
||||
>
|
||||
{sb.totalScore}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Signal score derivation (FUTURE_AVAILABILITY) ─────────────────────────────
|
||||
|
||||
interface SignalScoreDerivationProps {
|
||||
@@ -68,11 +225,15 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
||||
const probPct = signal ? Math.round(signal.probability * 100) : null
|
||||
const credLabel = signal ? (CREDIBILITY_LABELS[signal.source.credibility] ?? signal.source.credibility) : null
|
||||
|
||||
// Approximate signal deduction for display purposes
|
||||
const signalDeduction = signal && !isVerifiedContract
|
||||
? Math.round((1 - signal.probability) * sb.hardMatchScore * 0.18)
|
||||
: 0
|
||||
|
||||
// Use allFactors or fall back to pos + neg
|
||||
const displayFactors = match.allFactors
|
||||
? match.allFactors
|
||||
: [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Schritt 1 — Kriterien-Match */}
|
||||
@@ -84,13 +245,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
||||
Wie gut passt die Fläche, wenn das Signal eintrifft?
|
||||
</Typography>
|
||||
|
||||
{match.positiveFactors.map((f, i) => (
|
||||
{displayFactors.map((f, i) => {
|
||||
const isPositive = f.score >= 70
|
||||
return (
|
||||
<Box key={i} sx={{ mb: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||
{isPositive
|
||||
? <CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||
: <X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
|
||||
}
|
||||
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1a7a4a', ml: 1 }}>{f.score}/100</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{factorLabel(f.criterion)}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -103,28 +269,8 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
||||
{f.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{(match.negativeFactors ?? []).map((f, i) => (
|
||||
<Box key={i} sx={{ mb: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
||||
<X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', ml: 1 }}>{f.score}/100</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={f.score}
|
||||
color="error"
|
||||
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
|
||||
{f.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>Basis-Score</Typography>
|
||||
@@ -188,10 +334,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
|
||||
}}
|
||||
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
|
||||
>
|
||||
{sb.totalScore}/100
|
||||
</Typography>
|
||||
@@ -210,8 +353,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: Props) {
|
||||
const sb = match.scoreBreakdown
|
||||
|
||||
if (isFuture) {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
@@ -221,79 +362,10 @@ export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal
|
||||
)
|
||||
}
|
||||
|
||||
const hardColor = scoreColor(sb.hardMatchScore)
|
||||
const softColor = scoreColor(sb.softFactorScore)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Score Breakdown</Typography>
|
||||
|
||||
<BreakdownRow
|
||||
label="Hard-Kriterien (60%)"
|
||||
value={sb.hardMatchScore}
|
||||
max={100}
|
||||
description="Fläche, Standort, Budget, Timing"
|
||||
color={hardColor}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Soft Factors (40%)"
|
||||
value={sb.softFactorScore}
|
||||
max={100}
|
||||
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
|
||||
label="Datenqualitäts-Modifier"
|
||||
value={sb.dataQualityModifier}
|
||||
max={15}
|
||||
description="Basierend auf Vollständigkeit und Aktualität der Objektdaten"
|
||||
modifier
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Konfidenz-Modifier"
|
||||
value={sb.confidenceModifier}
|
||||
max={15}
|
||||
description="Basierend auf Quelltyp (Verified/Extern/Signal)"
|
||||
modifier
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
|
||||
}}
|
||||
>
|
||||
{sb.totalScore}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Bewertungsherleitung</Typography>
|
||||
<StandardBreakdown match={match} taxCalculatorUrl={taxCalculatorUrl} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface Match {
|
||||
// Explainability
|
||||
positiveFactors: ScoreFactor[]
|
||||
negativeFactors: ScoreFactor[]
|
||||
allFactors?: ScoreFactor[] // all scored criteria (hard + soft) — for full score transparency
|
||||
tradeoffs: TradeOff[]
|
||||
tradeOffs?: TradeOff[] // alias for F004 naming convention
|
||||
risks?: Risk[]
|
||||
|
||||
@@ -137,5 +137,11 @@ export function buildMatchCardViewModel(
|
||||
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
||||
preMarketUnit: unit,
|
||||
preMarketAllUnits: property?.units,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: match.scoreBreakdown.hardMatchScore,
|
||||
softFactorScore: match.scoreBreakdown.softFactorScore,
|
||||
totalScore: match.scoreBreakdown.totalScore,
|
||||
},
|
||||
allFactors: match.allFactors,
|
||||
}
|
||||
}
|
||||
|
||||
+151
-10
@@ -44,6 +44,7 @@ export const mockProperties: Property[] = [
|
||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-2024-001',
|
||||
description: 'Moderne Bürofläche im aufstrebenden Stadtquartier Zürich-West, direkt beim Trendviertel Freilager. Die hellen, offen gestalteten Flächen bieten optimale Bedingungen für kollaboratives Arbeiten. Grosszügige Fensterfronten sorgen für viel Tageslicht. Das Gebäude verfügt über einen repräsentativen Empfangsbereich, Sitzungsräume sowie eine Gemeinschaftsterrasse mit Blick auf die Stadt. ÖV-Anbindung in unmittelbarer Nähe (Tram 4/13, S-Bahn Hardbrücke, 4 Minuten zu Fuss).',
|
||||
|
||||
units: [
|
||||
{ id: 'unit-001-1', propertyId: 'prop-001', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
|
||||
@@ -99,6 +100,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-2022-002',
|
||||
description: 'Grossflächige Logistikanlage in direkter Rheinnähe, Kleinhüningen Basel. Zwei separate Lagerhallen A und B mit je eigenem Tor und Rampenanlage. Sprinkleranlage und Hallentemperierung vorhanden. Sehr gute Erschliessung via A2/A3, 30 Lastwagenstellplätze auf dem Areal. Ausbaupotenzial von 800 m² auf dem Grundstück verfügbar.',
|
||||
units: [
|
||||
{ id: 'unit-002-1', propertyId: 'prop-002', floorLevel: 0, unitLabel: 'Halle A', areaSqm: 1400, available: false, rentPricePerSqm: 168, currentTenant: 'Spedition Rhein GmbH', leaseTerm: '3 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } },
|
||||
{ id: 'unit-002-2', propertyId: 'prop-002', floorLevel: 0, unitLabel: 'Halle B', areaSqm: 1000, available: false, rentPricePerSqm: 168, currentTenant: 'Spedition Rhein GmbH', leaseTerm: '3 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } },
|
||||
@@ -155,6 +158,7 @@ export const mockProperties: Property[] = [
|
||||
images: ['https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-2021-007',
|
||||
description: 'Helle Büroflächen im 2. Obergeschoss an der Thurgauerstrasse, Zürich-Oerlikon. Drei Einheiten — zwei belegt und für Pre-Market freigegeben, eine flexible Einheit ab 150 m² direkt verfügbar. Hervorragende ÖV-Anbindung via Tram 11 und S-Bahn Oerlikon. Break-out-Option auf September 2026, danach gesamte 720 m² frei.',
|
||||
units: [
|
||||
{ id: 'unit-007-1', propertyId: 'prop-007', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
|
||||
{ id: 'unit-007-2', propertyId: 'prop-007', floorLevel: 1, unitLabel: '1.OG', areaSqm: 260, available: false, rentPricePerSqm: 432, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
|
||||
@@ -211,6 +215,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1568992687947-868a62a9f521?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-2021-008',
|
||||
description: 'Moderne Büroflächen im renommierten Dreispitz-Areal, Hochbergerstrasse Basel. Vier unabhängige Etagen für verschiedene Teams oder Mieter. Parkhaus im Gebäude vorhanden, hervorragende Anbindung an Tram 11. Derzeit vollvermietet an Pharma Research GmbH — alle Einheiten für Pre-Market freigegeben.',
|
||||
units: [
|
||||
{ id: 'unit-008-1', propertyId: 'prop-008', floorLevel: 1, unitLabel: '1.OG', areaSqm: 220, available: false, rentPricePerSqm: 384, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: true, availableFrom: '2027-08-31' } },
|
||||
{ id: 'unit-008-2', propertyId: 'prop-008', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 250, available: false, rentPricePerSqm: 384, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: true, availableFrom: '2027-08-31' } },
|
||||
@@ -266,6 +272,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-2022-009',
|
||||
description: 'Modernes Logistikzentrum im Töss-Quartier Winterthur, mit direkter A1-Anbindung. Lager Nord und Süd können separat oder gemeinsam angemietet werden. Ebene Andienung mit 4 Toren, Hallenhöhe 8 m, Bodenbelastung 3 t/m². 25 Lastwagenstellplätze. Ausbaupotenzial von 600 m² vorhanden.',
|
||||
units: [
|
||||
{ id: 'unit-009-1', propertyId: 'prop-009', floorLevel: 0, unitLabel: 'Lager Nord', areaSqm: 900, available: false, rentPricePerSqm: 156, currentTenant: 'Sperrgut Logistik AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
|
||||
{ id: 'unit-009-2', propertyId: 'prop-009', floorLevel: 0, unitLabel: 'Lager Süd', areaSqm: 900, available: false, rentPricePerSqm: 156, currentTenant: 'Sperrgut Logistik AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
|
||||
@@ -319,6 +327,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-2024-010',
|
||||
description: 'Exklusive Retailfläche am Löwenplatz, Zürich-Innenstadt. Direkter Zugang zu Bahnhof und Tramknotenpunkt, maximale Laufkundschaft rund um die Uhr. EG-Verkaufsfläche mit repräsentativer Schaufensterfront. Derzeit von Fashion Concept GmbH belegt — Pre-Market-Freigabe bereits aktiv.',
|
||||
units: [
|
||||
{ id: 'unit-010-1', propertyId: 'prop-010', floorLevel: 0, unitLabel: 'EG Verkauf', areaSqm: 205, available: false, rentPricePerSqm: 1056, currentTenant: 'Fashion Concept GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } },
|
||||
{ id: 'unit-010-2', propertyId: 'prop-010', floorLevel: -1, unitLabel: 'UG Lager', areaSqm: 80, available: false, rentPricePerSqm: 432, currentTenant: 'Fashion Concept GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: false } },
|
||||
@@ -372,6 +382,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-2023-011',
|
||||
description: 'Weiträumige Produktions- und Logistikhalle im Gewerbequartier Brünnen Bern. Kranbahn (5 t) in Halle West, Dreiphasenstrom 400V vorhanden. Gut angebunden an A12-Anschluss Bern-Bümpliz. Zwei Hallenabschnitte separat oder gemeinsam anmietbar. Ausbaupotenzial von 1200 m² auf dem Grundstück möglich.',
|
||||
units: [
|
||||
{ id: 'unit-011-1', propertyId: 'prop-011', floorLevel: 0, unitLabel: 'Halle West', areaSqm: 1600, available: false, rentPricePerSqm: 144, currentTenant: 'Metallbau Bern AG', leaseTerm: '11 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
|
||||
{ id: 'unit-011-2', propertyId: 'prop-011', floorLevel: 0, unitLabel: 'Halle Ost', areaSqm: 1200, available: false, rentPricePerSqm: 144, currentTenant: 'Metallbau Bern AG', leaseTerm: '11 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
|
||||
@@ -427,6 +439,7 @@ export const mockProperties: Property[] = [
|
||||
images: ['https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZG-2022-012',
|
||||
description: 'Repräsentative Büroetage im Stadtturm Zug, Industriestrasse 2. Panoramablick auf die Zuger Innenstadt und den See vom 5. Obergeschoss. Moderne Ausstattung mit Klimaanlage und Unterflurverkabelung. Ideal für Finanzdienstleister, Family Offices und internationale Unternehmen. Break-out-Option auf August 2026.',
|
||||
units: [
|
||||
{ id: 'unit-012-1', floorLevel: 3, unitLabel: '3.OG A', areaSqm: 180, available: false, rentPricePerSqm: 504, currentTenant: 'FinTech Zug AG', leaseTerm: '3 Jahre', leaseEndDate: '2025-09-30' },
|
||||
{ id: 'unit-012-2', floorLevel: 4, unitLabel: '4.OG', areaSqm: 190, available: false, rentPricePerSqm: 504, currentTenant: 'FinTech Zug AG', leaseTerm: '3 Jahre', leaseEndDate: '2025-09-30' },
|
||||
@@ -484,6 +497,7 @@ export const mockProperties: Property[] = [
|
||||
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-2021-013',
|
||||
description: 'Gemischte Gewerbe- und Bürofläche im Altstetten Park, direkt an der Badenerstrasse Zürich. Kombination aus EG-Laden und zwei Büroetagen — ideal für Firmen mit Showroom-Bedarf. Gut erschlossen via ÖV (Tram, Bus) und Autobahn. Flexible Einheiten ab 100 m² verfügbar.',
|
||||
units: [
|
||||
{ id: 'unit-013-1', floorLevel: 0, unitLabel: 'EG Laden', areaSqm: 320, available: false, rentPricePerSqm: 600, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
|
||||
{ id: 'unit-013-2', floorLevel: 1, unitLabel: '1.OG Büro A', areaSqm: 480, available: false, rentPricePerSqm: 540, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
|
||||
@@ -539,6 +553,8 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BL-2022-014',
|
||||
description: 'Dreiteilige Logistikhalle im Industriepark Pratteln, unmittelbar an der A2-Anschlussstelle. Lager A und B für Palettenlagerung ausgelegt, Bürobereich EG ideal als Kombi-Nutzung (ab 200 m² flexibel). Ausbaupotenzial von 1500 m² auf dem Grundstück. Break-out-Option auf Juli 2026.',
|
||||
importedFrom: 'SAP RE-FX',
|
||||
importedAt: '2025-01-15T08:00:00Z',
|
||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||
@@ -581,7 +597,7 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.62,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-04-10',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Mietpreis nicht bestätigt', 'Verfügbarkeit nicht verifiziert'],
|
||||
@@ -591,10 +607,18 @@ export const mockProperties: Property[] = [
|
||||
visibilityScore: 98,
|
||||
passerbyFrequency: 'VERY_HIGH',
|
||||
publicTransportMinutes: 2,
|
||||
parkingSpots: 0,
|
||||
},
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 6.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-003',
|
||||
description: 'Attraktive Ladenfläche an der Bahnhofstrasse Bern, direkt beim Hauptbahnhof. EG, sehr hohe Laufkundschaft, maximale Visibilität. Ideal für bekannte Handelsmarken oder Dienstleister mit direktem Kundenkontakt.',
|
||||
units: [
|
||||
{ id: 'unit-003-1', propertyId: 'prop-003', floorLevel: 0, areaSqm: 320, available: true, rentPricePerSqm: 1140 },
|
||||
],
|
||||
createdAt: '2025-02-15T14:00:00Z',
|
||||
updatedAt: '2025-04-10T09:00:00Z',
|
||||
},
|
||||
@@ -621,9 +645,17 @@ export const mockProperties: Property[] = [
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Daten aus Drittquelle – nicht verifiziert'],
|
||||
},
|
||||
softFactors: { prestige: 72, accessibility: 90, visibilityScore: 70, talentAccess: 78, publicTransportMinutes: 3 },
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 5.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1498049794561-7780e7231661?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-004',
|
||||
description: 'Grosszügige Gewerbefläche an der Europaallee, Zürich Kreis 4, direkt beim Hauptbahnhof. Modernes Gebäude mit flexibler Aufteilung. Hervorragende ÖV-Anbindung und erstklassige Lage. Geeignet als Büro, Showroom oder gemischte Nutzung.',
|
||||
units: [
|
||||
{ id: 'unit-004-1', propertyId: 'prop-004', floorLevel: 2, areaSqm: 1150, available: true, rentPricePerSqm: 624 },
|
||||
],
|
||||
createdAt: '2025-03-01T10:00:00Z',
|
||||
updatedAt: '2025-03-20T15:00:00Z',
|
||||
},
|
||||
@@ -655,10 +687,19 @@ export const mockProperties: Property[] = [
|
||||
prestige: 74,
|
||||
accessibility: 86,
|
||||
publicTransportMinutes: 5,
|
||||
talentAccess: 76,
|
||||
parkingSpots: 4,
|
||||
},
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 5.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1556761175-b413da4baf72?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'LU-MW-015',
|
||||
description: 'Helle Bürofläche am Kasernenplatz Luzern, im Herzen der Innenstadt. Das 3. Obergeschoss bietet eine grosszügige, flexible Raumaufteilung mit natürlichem Licht von drei Seiten. ÖV-Verbindungen in unmittelbarer Nähe (Bahnhof Luzern, 5 Minuten zu Fuss). Ideal für professionelle Dienstleister.',
|
||||
units: [
|
||||
{ id: 'unit-015-1', propertyId: 'prop-015', floorLevel: 3, areaSqm: 650, available: true, rentPricePerSqm: 456 },
|
||||
],
|
||||
createdAt: '2025-03-12T11:00:00Z',
|
||||
updatedAt: '2025-04-05T10:00:00Z',
|
||||
},
|
||||
@@ -690,10 +731,18 @@ export const mockProperties: Property[] = [
|
||||
prestige: 42,
|
||||
accessibility: 88,
|
||||
parkingSpots: 35,
|
||||
publicTransportMinutes: 10,
|
||||
},
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 3.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1525498128493-380d1990a112?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BL-MW-016',
|
||||
description: 'Grosszügige Lagerhalle an der Rheinfelderstrasse, direkt im Industriegebiet Muttenz. Ebenerdige Anlieferung mit breitem Tor, 35 Aussenparkplätze. Anbindung A2/A3 unter 5 Minuten. Ideal für Lagerung, Distribution und Leichtindustrie.',
|
||||
units: [
|
||||
{ id: 'unit-016-1', propertyId: 'prop-016', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2200, available: true, rentPricePerSqm: 192 },
|
||||
],
|
||||
createdAt: '2025-02-20T09:00:00Z',
|
||||
updatedAt: '2025-03-28T12:00:00Z',
|
||||
},
|
||||
@@ -716,7 +765,7 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.61,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['floorLevel', 'ancillaryCosts'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-04-18',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Mietpreis nicht final bestätigt'],
|
||||
@@ -726,10 +775,18 @@ export const mockProperties: Property[] = [
|
||||
visibilityScore: 94,
|
||||
passerbyFrequency: 'VERY_HIGH',
|
||||
publicTransportMinutes: 3,
|
||||
parkingSpots: 0,
|
||||
},
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 7.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1556742502-ec7c0e9f34b6?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-017',
|
||||
description: 'Erstklassige Ladenfläche an der Löwenstrasse im Zürcher Hauptbahnhof-Umfeld. EG, direkt an der Fussgängerzone mit sehr hoher Frequenz durch Pendler und Touristen. Schaufensterfront auf zwei Seiten.',
|
||||
units: [
|
||||
{ id: 'unit-017-1', propertyId: 'prop-017', floorLevel: 0, areaSqm: 350, available: true, rentPricePerSqm: 1140 },
|
||||
],
|
||||
createdAt: '2025-03-05T13:00:00Z',
|
||||
updatedAt: '2025-04-18T11:00:00Z',
|
||||
},
|
||||
@@ -752,7 +809,7 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.57,
|
||||
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
|
||||
missingOptionalFields: ['floorLevel'],
|
||||
missingOptionalFields: [],
|
||||
lastVerifiedAt: '2025-04-02',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Daten aus Drittquelle', 'Renovierungsstand unklar'],
|
||||
@@ -761,10 +818,18 @@ export const mockProperties: Property[] = [
|
||||
prestige: 62,
|
||||
accessibility: 78,
|
||||
publicTransportMinutes: 8,
|
||||
parkingSpots: 6,
|
||||
},
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 4.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-018',
|
||||
description: 'Praktische Bürofläche im 2. Obergeschoss, Breitenrainstrasse Bern. Grosszügige, lichtdurchflutete Räume in ruhigem Quartier nahe dem Stadtzentrum. Bahn- und Busanbindung in 8 Minuten Fussweg. Ideal für Büros, Beratungsfirmen oder Praxen.',
|
||||
units: [
|
||||
{ id: 'unit-018-1', propertyId: 'prop-018', floorLevel: 2, areaSqm: 780, available: true, rentPricePerSqm: 372 },
|
||||
],
|
||||
createdAt: '2025-02-28T10:00:00Z',
|
||||
updatedAt: '2025-04-02T09:00:00Z',
|
||||
},
|
||||
@@ -792,9 +857,17 @@ export const mockProperties: Property[] = [
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Hallenhöhe nicht verifiziert', 'Kranbahn Status unklar'],
|
||||
},
|
||||
softFactors: { prestige: 38, accessibility: 78, parkingSpots: 28, publicTransportMinutes: 12 },
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.8,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1572021335469-31706a17aaef?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-MW-019',
|
||||
description: 'Grosszügige Produktionshalle mit erhöhten Bodenlasten und Dreiphasenstrom im Industriequartier Kleinhüningen Basel. Direkte LKW-Zufahrt über die Voltastrasse. Ideal für Leichtindustrie, Montage oder Lagerhaltung.',
|
||||
units: [
|
||||
{ id: 'unit-019-1', propertyId: 'prop-019', floorLevel: 0, unitLabel: 'Produktionshalle', areaSqm: 1900, available: true, rentPricePerSqm: 156 },
|
||||
],
|
||||
createdAt: '2025-03-10T08:00:00Z',
|
||||
updatedAt: '2025-03-25T14:00:00Z',
|
||||
},
|
||||
@@ -826,10 +899,18 @@ export const mockProperties: Property[] = [
|
||||
prestige: 68,
|
||||
accessibility: 82,
|
||||
publicTransportMinutes: 9,
|
||||
parkingSpots: 10,
|
||||
},
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 5.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZG-MW-020',
|
||||
description: 'Grosszügige Bürofläche im 4. Obergeschoss, Industriestrasse Zug. Ruhige Lage mit Panorama-Bergblick, direkter Autobahnanschluss A4. Ideal für Firmen, die von der Zuger Steuerpolitik profitieren möchten, ohne Premium-Innenstadtmieten zu zahlen.',
|
||||
units: [
|
||||
{ id: 'unit-020-1', propertyId: 'prop-020', floorLevel: 4, areaSqm: 820, available: true, rentPricePerSqm: 528 },
|
||||
],
|
||||
createdAt: '2025-03-18T09:00:00Z',
|
||||
updatedAt: '2025-04-12T11:00:00Z',
|
||||
},
|
||||
@@ -852,7 +933,7 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.59,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-04-08',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Prix non confirmé', 'Disponibilité à vérifier'],
|
||||
@@ -862,10 +943,18 @@ export const mockProperties: Property[] = [
|
||||
visibilityScore: 96,
|
||||
passerbyFrequency: 'VERY_HIGH',
|
||||
publicTransportMinutes: 3,
|
||||
parkingSpots: 0,
|
||||
},
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 6.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'GE-MW-021',
|
||||
description: 'Exklusive Verkaufsfläche im Erdgeschoss an der Rue du Rhône, Genf Zentrum. Eine der prestigeträchtigsten Einkaufsstrassen der Schweiz. Ideal für Luxusmarken, Juweliere oder hochwertige Dienstleister mit Anforderung an Prestige und Visibilität.',
|
||||
units: [
|
||||
{ id: 'unit-021-1', propertyId: 'prop-021', floorLevel: 0, areaSqm: 250, available: true, rentPricePerSqm: 1344 },
|
||||
],
|
||||
createdAt: '2025-02-10T10:00:00Z',
|
||||
updatedAt: '2025-04-08T09:00:00Z',
|
||||
},
|
||||
@@ -888,7 +977,7 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.58,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-04-14',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Daten aus Drittquelle', 'Ausbauqualität nicht bestätigt'],
|
||||
@@ -897,10 +986,18 @@ export const mockProperties: Property[] = [
|
||||
prestige: 66,
|
||||
accessibility: 80,
|
||||
publicTransportMinutes: 6,
|
||||
parkingSpots: 8,
|
||||
},
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 4.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'SG-EXT-022',
|
||||
description: 'Helle Bürofläche an der Marktgasse im Zentrum St. Gallens. Das 2. Obergeschoss bietet eine zusammenhängende, gut aufteilbare Fläche in historischem Stadtquartier. St. Gallen HB in 6 Minuten zu Fuss. Ideal für Kanzleien, Dienstleister oder regionale Niederlassungen.',
|
||||
units: [
|
||||
{ id: 'unit-022-1', propertyId: 'prop-022', floorLevel: 2, areaSqm: 700, available: true, rentPricePerSqm: 336 },
|
||||
],
|
||||
createdAt: '2025-03-08T08:00:00Z',
|
||||
updatedAt: '2025-04-14T10:00:00Z',
|
||||
},
|
||||
@@ -930,6 +1027,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Mietpreis geschätzt'],
|
||||
},
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: möglicher Auszug eines Produktionsmieters in der Industriezone Reinach BL. Fläche und Preis sind Schätzwerte auf Basis vergleichbarer Objekte. Weitgehende Unsicherheit über Zeitpunkt und finale Konditionen — frühzeitige Markterkundung empfohlen.',
|
||||
createdAt: '2025-05-03T08:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
},
|
||||
@@ -954,7 +1052,9 @@ export const mockProperties: Property[] = [
|
||||
freshness: DataFreshness.FRESH,
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Mietpreis geschätzt'],
|
||||
},
|
||||
softFactors: { accessibility: 76, publicTransportMinutes: 8 },
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: erkannte Indikatoren deuten auf Auszug eines Büromieters in Seebach, Zürich-Nord hin. Flächengrösse und Konditionen sind Schätzwerte auf Basis ähnlicher Objekte an der Binzmühlestrasse. Verlässlichkeit: Mittel.',
|
||||
createdAt: '2025-04-14T08:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
},
|
||||
@@ -980,6 +1080,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Baubewilligung erteilt, Mieter noch nicht bekannt', 'Konditionen geschätzt'],
|
||||
},
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Signal eines neuen Logistikgebäudes im Klybeck-Hafen-Areal Basel. Baubewilligung erteilt, Neubau-Projekt in Entwicklung. Ideale Lage für Nordwestschweiz-Distribution. Konditionen und Mieterauswahl noch in Verhandlung — frühzeitiger Kontakt möglich.',
|
||||
createdAt: '2025-01-20T09:00:00Z',
|
||||
updatedAt: '2025-05-10T09:00:00Z',
|
||||
},
|
||||
@@ -1005,6 +1106,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Preis geschätzt'],
|
||||
},
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: erkannte Indikatoren deuten auf Mieterwechsel in der Münstergasse, Zürich Niederdorf hin. Preis-Schätzwert basiert auf Vergleichsobjekten in der Altstadt. Verlässlichkeit: Mittel — Lage hat höchste Fussgängerfrequenz.',
|
||||
createdAt: '2025-04-02T08:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
},
|
||||
@@ -1030,6 +1132,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Daten nicht verifiziert'],
|
||||
},
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: möglicher Mieterwechsel in der Industriezone Münchenbuchsee BE. Schätzung ca. 2200 m² Hallenfläche. Konditionen und Zeitpunkt unbestätigt — Objekt eignet sich zur frühzeitigen Marktbeobachtung.',
|
||||
createdAt: '2025-03-14T08:00:00Z',
|
||||
updatedAt: '2025-05-09T09:00:00Z',
|
||||
},
|
||||
@@ -1055,6 +1158,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Konditionen unbekannt'],
|
||||
},
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: grosses Logistiklager in Frenkendorf BL mit erkanntem Auszugs-Indikator. Ca. 3500 m² — für seltene Grossflächen im Basler Umland ein relevanter Frühindikator. Konditionen und Verfügbarkeit unbestätigt.',
|
||||
createdAt: '2025-04-08T08:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
},
|
||||
@@ -1087,9 +1191,16 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Daten aus Drittquelle'],
|
||||
},
|
||||
softFactors: { prestige: 76, accessibility: 88, visibilityScore: 62, talentAccess: 82, parkingSpots: 8, publicTransportMinutes: 4 },
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 4.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1542314831-068cd1dbfeeb?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-031',
|
||||
description: 'Repräsentative Bürofläche im 3. Obergeschoss im modernen Hardturm-Areal Zürich-West. Die zusammenhängende Fläche von 780 m² ist offen gestaltet und kann flexibel unterteilt werden. Grosse Fensterflächen, Klimaanlage und ein Untergeschoss-Parkhaus sind vorhanden. Das Gebäude befindet sich in zentraler Lage mit hervorragender Anbindung an den öffentlichen Verkehr (Tram 4/13, Bahnhof Hardbrücke, 4 Minuten zu Fuss). Übergabe ab Oktober 2025 möglich.',
|
||||
units: [
|
||||
{ id: 'unit-031-1', propertyId: 'prop-031', floorLevel: 3, areaSqm: 780, available: true, rentPricePerSqm: 420 },
|
||||
],
|
||||
createdAt: '2025-04-25T10:00:00Z',
|
||||
updatedAt: '2025-05-10T09:00:00Z',
|
||||
},
|
||||
@@ -1118,9 +1229,16 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Ausbaustandard nicht bestätigt'],
|
||||
},
|
||||
softFactors: { prestige: 74, accessibility: 86, visibilityScore: 60, talentAccess: 80, parkingSpots: 6, publicTransportMinutes: 5 },
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 5.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1613545325278-f24b0cae1224?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-MW-032',
|
||||
description: 'Stilvolles Büroloft im 5. Obergeschoss eines ehemaligen Industriegebäudes an der Pfingstweidstrasse, Kreis 5. Die offene Loftstruktur mit Sichtbetondecken und -wänden schafft ein inspirierendes Arbeitsumfeld. Raumhöhe ca. 3,5 m, Holzböden, individuelle Klimatisierung. Panoramablick über die Dächer Zürichs. Ideal für kreative Unternehmen und Tech-Firmen. Verfügbar ab November 2025.',
|
||||
units: [
|
||||
{ id: 'unit-032-1', propertyId: 'prop-032', floorLevel: 5, areaSqm: 720, available: true, rentPricePerSqm: 480 },
|
||||
],
|
||||
createdAt: '2025-04-20T11:00:00Z',
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
},
|
||||
@@ -1143,15 +1261,22 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.60,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-05-05',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Mietpreis nicht final bestätigt'],
|
||||
},
|
||||
softFactors: { prestige: 88, visibilityScore: 92, passerbyFrequency: 'HIGH', accessibility: 92, publicTransportMinutes: 3 },
|
||||
softFactors: { prestige: 88, visibilityScore: 92, passerbyFrequency: 'HIGH', accessibility: 92, publicTransportMinutes: 3, parkingSpots: 0 },
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 7.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-MW-033',
|
||||
description: 'Attraktive Retailfläche an der Marktgasse, direkt in der Berner Fussgängerzone. EG-Fläche mit hoher Laufkundschaft durch Tourismus und Pendler. Tram und Bus direkt vor dem Haus. Ideal für Fashion, Lifestyle oder Gastronomie-Konzepte.',
|
||||
units: [
|
||||
{ id: 'unit-033-1', propertyId: 'prop-033', floorLevel: 0, areaSqm: 260, available: true, rentPricePerSqm: 1440 },
|
||||
],
|
||||
createdAt: '2025-03-28T09:00:00Z',
|
||||
updatedAt: '2025-05-05T10:00:00Z',
|
||||
},
|
||||
@@ -1174,15 +1299,22 @@ export const mockProperties: Property[] = [
|
||||
dataQuality: {
|
||||
score: 0.56,
|
||||
missingCriticalFields: ['contractDurationMonths'],
|
||||
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||
missingOptionalFields: ['ancillaryCosts'],
|
||||
lastVerifiedAt: '2025-04-22',
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Daten aus Drittquelle', 'Schaufensterfront nicht bestätigt'],
|
||||
},
|
||||
softFactors: { prestige: 72, visibilityScore: 78, passerbyFrequency: 'MEDIUM', accessibility: 82, publicTransportMinutes: 6 },
|
||||
softFactors: { prestige: 72, visibilityScore: 78, passerbyFrequency: 'MEDIUM', accessibility: 82, publicTransportMinutes: 6, parkingSpots: 4 },
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 6.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1534398079543-7ae6d016b86a?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-034',
|
||||
description: 'Ansprechende Ladenfläche im trendigen Quartier Lorraine, Bern. EG mit Schaufensterfront, gut positioniert für Kreativwirtschaft und gehobene Kundschaft. Busanbindung in 6 Minuten zum Bahnhof Bern.',
|
||||
units: [
|
||||
{ id: 'unit-034-1', propertyId: 'prop-034', floorLevel: 0, areaSqm: 340, available: true, rentPricePerSqm: 1320 },
|
||||
],
|
||||
createdAt: '2025-04-08T10:00:00Z',
|
||||
updatedAt: '2025-04-22T09:00:00Z',
|
||||
},
|
||||
@@ -1208,6 +1340,7 @@ export const mockProperties: Property[] = [
|
||||
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Mietpreis geschätzt'],
|
||||
},
|
||||
riskLevel: RiskLevel.HIGH,
|
||||
description: 'Probabilistisches Signal: erkannte Indikatoren deuten auf Mieterwechsel in der Gerechtigkeitsgasse, Berner Altstadt hin. Erstklassige Lage in der historischen Einkaufsmeile. Preis-Schätzwert auf Basis ähnlicher Altstadtflächen. Verlässlichkeit: Mittel.',
|
||||
createdAt: '2025-04-28T09:00:00Z',
|
||||
updatedAt: '2025-05-15T10:00:00Z',
|
||||
},
|
||||
@@ -1235,10 +1368,17 @@ export const mockProperties: Property[] = [
|
||||
freshness: DataFreshness.STALE,
|
||||
warnings: ['Hallenhöhe nicht bestätigt', 'Daten aus Drittquelle'],
|
||||
},
|
||||
softFactors: { prestige: 44, accessibility: 90, parkingSpots: 38 },
|
||||
softFactors: { prestige: 44, accessibility: 90, parkingSpots: 38, publicTransportMinutes: 14 },
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.5,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
images: ['https://images.unsplash.com/photo-1590239926044-4131a46e3f27?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-EXT-036',
|
||||
description: 'Freistehendes Lager-/Logistikgebäude direkt an der Klybeckstrasse, Basel Hafen. Ebenerdige Anlieferung, 38 Aussenparkplätze. Hervorragende Lage für Distribution in der Nordwestschweiz. Kran und Sprinkleranlage vorhanden.',
|
||||
units: [
|
||||
{ id: 'unit-036-1', propertyId: 'prop-036', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2600, available: true, rentPricePerSqm: 180 },
|
||||
],
|
||||
createdAt: '2025-04-15T08:00:00Z',
|
||||
updatedAt: '2025-05-02T10:00:00Z',
|
||||
},
|
||||
@@ -1280,6 +1420,7 @@ export const mockProperties: Property[] = [
|
||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-2024-037',
|
||||
description: 'Erstklassige Retailfläche an der Spitalgasse, direkt im Herzstück der Berner Innenstadt. Direkter Anschluss an Bahnhof und Tramknotenpunkt — maximale Erreichbarkeit. EG-Verkaufsfläche mit grosser Schaufensterfront. Derzeit von Modehaus Bern AG belegt, Pre-Market-Freigabe bereits aktiv.',
|
||||
units: [
|
||||
{ id: 'unit-037-1', propertyId: 'prop-037', floorLevel: 0, unitLabel: 'EG Verkaufsfläche', areaSqm: 190, available: false, rentPricePerSqm: 1080, currentTenant: 'Modehaus Bern AG', leaseTerm: '5 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
|
||||
{ id: 'unit-037-2', propertyId: 'prop-037', floorLevel: 0, unitLabel: 'EG Lager/Nebenräume', areaSqm: 80, available: false, rentPricePerSqm: 720, currentTenant: 'Modehaus Bern AG', leaseTerm: '5 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: false } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
|
||||
import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
|
||||
import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, ShieldCheck, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { propertyService } from '../../services/propertyService'
|
||||
@@ -27,13 +28,80 @@ import {
|
||||
} from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
|
||||
// ── Property detail helpers ────────────────────────────────────────────────────
|
||||
|
||||
const FLOOR_LABEL = (level: number) =>
|
||||
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
|
||||
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
|
||||
}
|
||||
const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24',
|
||||
HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice',
|
||||
NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst',
|
||||
}
|
||||
const PASSERBY_LABELS: Record<string, string> = {
|
||||
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
|
||||
}
|
||||
|
||||
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
|
||||
if (unit.schattenmarktRelease?.enabled) {
|
||||
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
if (unit.available) {
|
||||
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
|
||||
}
|
||||
|
||||
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
|
||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
|
||||
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
|
||||
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
|
||||
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
|
||||
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
|
||||
</Typography>
|
||||
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'}
|
||||
</Typography>
|
||||
<UnitStatusChip unit={unit} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Match helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
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: 'Direktinserat', color: '#d97706' },
|
||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
|
||||
@@ -273,6 +341,161 @@ export default function MatchDetail() {
|
||||
<ExecutiveSummaryPanel match={match} />
|
||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||
|
||||
{/* ── Property Details ── */}
|
||||
{!isFuture && property && (() => {
|
||||
const units = property.units ?? []
|
||||
const matchedUnit = units.find(u => u.id === match.unitId)
|
||||
const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null)
|
||||
const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled)
|
||||
const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled)
|
||||
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
|
||||
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
||||
const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined)
|
||||
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Preis */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Tag size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
||||
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.–`} />
|
||||
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`} />
|
||||
{property.ancillaryCosts != null && (
|
||||
<KeyFactRow label="Nebenkosten" value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Hauptangaben */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
|
||||
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
|
||||
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')} m²`} />
|
||||
{minLettable != null && <KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')} m²`} />}
|
||||
{property.contractDurationMonths != null && <KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />}
|
||||
{(property.floorLevel != null || matchedUnit) && (
|
||||
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL((matchedUnit?.floorLevel ?? property.floorLevel)!)} />
|
||||
)}
|
||||
{property.currentTenant && <KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />}
|
||||
{property.leaseEndDate && <KeyFactRow label="Mietvertragsende" value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })} />}
|
||||
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
|
||||
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
|
||||
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />}
|
||||
</Paper>
|
||||
|
||||
{/* Eigenschaften */}
|
||||
{property.softFactors && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{property.softFactors.publicTransportMinutes != null && (
|
||||
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.passerbyFrequency && (
|
||||
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Wegzeit */}
|
||||
{property.softFactors?.publicTransportMinutes != null && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Clock size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||
<Train size={18} color="#1d4ed8" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Nächster ÖV-Anschluss — {property.location.city}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Die Zeiten beziehen sich auf die Strecke zu Fuss.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Einheiten */}
|
||||
{units.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Layers size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
|
||||
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
{preMarketUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId || preMarketUnits.length === 1} />)}
|
||||
{otherUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId} />)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Beschreibung */}
|
||||
{property.description && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Building2 size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||
{property.description}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Quelle & Referenz */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||
{property.propertyNumber && <KeyFactRow label="Objektnummer" value={property.propertyNumber} />}
|
||||
{property.importedFrom && <KeyFactRow label="Importiert aus" value={property.importedFrom} />}
|
||||
{property.dataQuality.lastVerifiedAt && (
|
||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
|
||||
Zum Originalinserat
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{reasons.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
|
||||
|
||||
@@ -16,17 +16,60 @@ import {
|
||||
Building2,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Layers,
|
||||
Mail,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
Tag,
|
||||
Train,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const FLOOR_LABEL = (level: number) =>
|
||||
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
|
||||
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
|
||||
}
|
||||
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch',
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
ERP_IMPORT: 'ERP-Import (intern)',
|
||||
IMMOSCOUT_SCRAPE: 'ImmoScout24',
|
||||
HOMEGATE_SCRAPE: 'Homegate',
|
||||
MATCHOFFICE_SCRAPE: 'MatchOffice',
|
||||
NEWHOME_SCRAPE: 'newhome.ch',
|
||||
AI_SIGNAL: 'KI-Signal',
|
||||
MANUAL: 'Manuell erfasst',
|
||||
}
|
||||
|
||||
const PASSERBY_LABELS: Record<string, string> = {
|
||||
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
|
||||
if (unit.schattenmarktRelease?.enabled) {
|
||||
return (
|
||||
@@ -85,6 +128,8 @@ function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boole
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PropertyDetail() {
|
||||
const { propertyId } = useParams<{ propertyId: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -115,7 +160,19 @@ export default function PropertyDetail() {
|
||||
|
||||
const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
|
||||
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
|
||||
const monthlyRentDisplay = Math.round(property.rentPricePerSqm / 12)
|
||||
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
|
||||
|
||||
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
|
||||
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
||||
const minLettable = property.areaSqmMin
|
||||
?? (flexibleUnits.length > 0
|
||||
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
|
||||
: undefined)
|
||||
|
||||
const sourceLabel = property.sourceLabel
|
||||
?? property.sourceMeta?.sourceLabel
|
||||
?? SOURCE_LABELS[property.sourceType]
|
||||
?? property.sourceType
|
||||
|
||||
function handleSendInquiry() {
|
||||
if (!inquiryName.trim() || !inquiryText.trim()) return
|
||||
@@ -124,7 +181,8 @@ export default function PropertyDetail() {
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}>
|
||||
{/* Back */}
|
||||
|
||||
{/* ── Back ── */}
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={15} />}
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -134,7 +192,7 @@ export default function PropertyDetail() {
|
||||
Zurück zu den Ergebnissen
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
{/* ── Header ── */}
|
||||
<Paper sx={{ mb: 2, overflow: 'hidden' }}>
|
||||
{property.images?.[0] && (
|
||||
<Box
|
||||
@@ -150,7 +208,7 @@ export default function PropertyDetail() {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
<Building2 size={15} color="#7c3aed" />
|
||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{property.assetType}
|
||||
{ASSET_LABELS[property.assetType] ?? property.assetType}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{property.title}</Typography>
|
||||
@@ -163,7 +221,7 @@ export default function PropertyDetail() {
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
|
||||
CHF {monthlyRentDisplay}/m²/Mt.
|
||||
CHF {monthlyPerSqm.toLocaleString('de-CH')}/m²/Mt.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{property.areaSqm.toLocaleString('de-CH')} m² total</Typography>
|
||||
</Box>
|
||||
@@ -180,7 +238,178 @@ export default function PropertyDetail() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Units */}
|
||||
{/* ── Preis ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Tag size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||
</Box>
|
||||
<KeyFactRow
|
||||
label="Monatliche Miete"
|
||||
value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`}
|
||||
/>
|
||||
<KeyFactRow
|
||||
label="Pro m²/Monat"
|
||||
value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.–`}
|
||||
/>
|
||||
<KeyFactRow
|
||||
label="Pro m²/Jahr"
|
||||
value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`}
|
||||
/>
|
||||
{property.ancillaryCosts != null && (
|
||||
<KeyFactRow
|
||||
label="Nebenkosten"
|
||||
value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Hauptangaben ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||
</Box>
|
||||
<KeyFactRow
|
||||
label="Verfügbarkeit"
|
||||
value={
|
||||
property.availabilityDate
|
||||
? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: 'Auf Anfrage'
|
||||
}
|
||||
/>
|
||||
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
|
||||
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')} m²`} />
|
||||
{minLettable != null && (
|
||||
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')} m²`} />
|
||||
)}
|
||||
{property.contractDurationMonths != null && (
|
||||
<KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />
|
||||
)}
|
||||
{property.floorLevel != null && (
|
||||
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL(property.floorLevel)} />
|
||||
)}
|
||||
{property.currentTenant && (
|
||||
<KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />
|
||||
)}
|
||||
{property.leaseEndDate && (
|
||||
<KeyFactRow
|
||||
label="Mietvertragsende"
|
||||
value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||
/>
|
||||
)}
|
||||
{property.breakoutOption && (
|
||||
<KeyFactRow
|
||||
label="Break-out Option"
|
||||
value={
|
||||
property.breakoutOptionDate
|
||||
? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
|
||||
: 'Ja'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{property.riskLevel && (
|
||||
<KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />
|
||||
)}
|
||||
{property.expansionPotentialSqm != null && (
|
||||
<KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Eigenschaften ── */}
|
||||
{property.softFactors && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{property.softFactors.publicTransportMinutes != null && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<Train size={11} />}
|
||||
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${property.softFactors.parkingSpots} Parkplätze`}
|
||||
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Prestigestandort"
|
||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Hohe Sichtbarkeit"
|
||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.passerbyFrequency && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
|
||||
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Hoher Talentzugang"
|
||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Talentindex: ${property.softFactors.talentAccess}`}
|
||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Wegzeit ── */}
|
||||
{property.softFactors?.publicTransportMinutes != null && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Clock size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||
<Train size={18} color="#1d4ed8" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{property.softFactors.publicTransportMinutes} Min. zu Fuss
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Nächster ÖV-Anschluss — {property.location.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{property.softFactors.infrastructureNotes && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
|
||||
{property.softFactors.infrastructureNotes}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Die Zeiten beziehen sich auf die Strecke zu Fuss.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Einheiten ── */}
|
||||
{(property.units ?? []).length > 0 && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
@@ -188,7 +417,6 @@ export default function PropertyDetail() {
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Column headers */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
|
||||
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
|
||||
@@ -204,7 +432,56 @@ export default function PropertyDetail() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Inquiry */}
|
||||
{/* ── Beschreibung ── */}
|
||||
{property.description && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Building2 size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||
{property.description}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Quelle & Referenz ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||
{property.propertyNumber && (
|
||||
<KeyFactRow label="Objektnummer" value={property.propertyNumber} />
|
||||
)}
|
||||
{property.importedFrom && (
|
||||
<KeyFactRow label="Importiert aus" value={property.importedFrom} />
|
||||
)}
|
||||
{property.dataQuality.lastVerifiedAt && (
|
||||
<KeyFactRow
|
||||
label="Zuletzt verifiziert"
|
||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
/>
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<ExternalLink size={12} />}
|
||||
href={property.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
|
||||
>
|
||||
Zum Originalinserat
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Verwaltung kontaktieren ── */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Mail size={15} color="#374151" />
|
||||
@@ -242,7 +519,7 @@ export default function PropertyDetail() {
|
||||
? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
|
||||
: property.title
|
||||
}
|
||||
InputProps={{ readOnly: true }}
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||
import type { Match } from '../domain/match'
|
||||
import { mockMatches } from '../mock-data/matches'
|
||||
|
||||
export const matchStore: Match[] = [...mockMatches]
|
||||
// Matches are computed dynamically via calculateScore so weights always reflect need.weightingProfile
|
||||
export const matchStore: Match[] = []
|
||||
const store = matchStore
|
||||
|
||||
export const MockupMatchProvider: IMatchProvider = {
|
||||
|
||||
@@ -3,95 +3,35 @@ import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
import type { MatchEngineOutput } from '../domain/scoring'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
// ── Location scoring ───────────────────────────────────────────────────────────
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CANTON_MAP: Record<string, string> = {
|
||||
zürich: 'zh', zug: 'zg', winterthur: 'zh', uster: 'zh', bülach: 'zh', oerlikon: 'zh',
|
||||
bern: 'be', biel: 'be', thun: 'be', köniz: 'be',
|
||||
basel: 'bs', muttenz: 'bl', pratteln: 'bl', reinach: 'bl', allschwil: 'bl', binningen: 'bl',
|
||||
genf: 'ge', genève: 'ge', carouge: 'ge', lancy: 'ge',
|
||||
'st. gallen': 'sg', 'st.gallen': 'sg', rapperswil: 'sg',
|
||||
}
|
||||
|
||||
function locationScore(propCity: string, preferredLocations: string[]): number {
|
||||
const pc = propCity.toLowerCase()
|
||||
for (const pref of preferredLocations) {
|
||||
const p = pref.toLowerCase()
|
||||
if (pc.includes(p) || p.includes(pc)) return 1.0
|
||||
}
|
||||
const propCanton = CANTON_MAP[pc]
|
||||
if (propCanton) {
|
||||
for (const pref of preferredLocations) {
|
||||
const prefCanton = CANTON_MAP[pref.toLowerCase()]
|
||||
if (prefCanton && prefCanton === propCanton) return 0.55
|
||||
}
|
||||
}
|
||||
return 0.30
|
||||
}
|
||||
|
||||
function computeScore(
|
||||
prop: {
|
||||
assetType: string
|
||||
location: { city: string }
|
||||
resultType?: string
|
||||
},
|
||||
need: Need,
|
||||
areaSqm: number,
|
||||
rentPricePerSqm: number | undefined,
|
||||
isPreMarket = false,
|
||||
): number | null {
|
||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||
|
||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||
|
||||
if (need.requiredArea && areaSqm) {
|
||||
const { min, max } = need.requiredArea
|
||||
if (areaSqm >= min && areaSqm <= max) score += 20
|
||||
else if (areaSqm >= min * 0.7 && areaSqm <= max * 1.5) score += 10
|
||||
else if (areaSqm < min * 0.5 || areaSqm > max * 2) score -= 10
|
||||
}
|
||||
|
||||
if (need.budgetRange?.maxPerSqm && rentPricePerSqm) {
|
||||
const monthlyRate = rentPricePerSqm / 12
|
||||
if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10
|
||||
else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||
else score -= 8
|
||||
}
|
||||
|
||||
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
||||
score = Math.round(score * 0.82)
|
||||
} else if (isPreMarket) {
|
||||
score = Math.round(score * 0.92)
|
||||
}
|
||||
|
||||
score += Math.floor(Math.random() * 6) - 2
|
||||
return Math.min(97, Math.max(22, score))
|
||||
}
|
||||
|
||||
function strengthFromScore(s: number): string {
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function buildMatch(
|
||||
prop: typeof propertyStore[0],
|
||||
prop: Property,
|
||||
unitId: string | undefined,
|
||||
need: Need,
|
||||
score: number,
|
||||
output: MatchEngineOutput,
|
||||
effectiveResultType: string,
|
||||
resultId: string,
|
||||
isGoodLoc: boolean,
|
||||
areaLabel: string,
|
||||
now: string,
|
||||
): Match {
|
||||
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
|
||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
@@ -99,27 +39,22 @@ function buildMatch(
|
||||
needId: need.id,
|
||||
resultId,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
matchScore: output.finalScore,
|
||||
matchStrength: strengthFromScore(output.finalScore),
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
hardMatchScore: output.hardMatchScore,
|
||||
softFactorScore: output.softFactorScore,
|
||||
confidenceModifier: output.confidenceModifier,
|
||||
dataQualityModifier: output.dataQualityModifier,
|
||||
totalScore: output.finalScore,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${areaLabel} verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
positiveFactors: output.positiveFactors,
|
||||
negativeFactors: output.negativeFactors,
|
||||
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
|
||||
tradeoffs: output.tradeOffs ?? [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. ${areaLabel} entspricht den Kernkriterien.`
|
||||
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
@@ -130,56 +65,50 @@ function buildMatch(
|
||||
}
|
||||
}
|
||||
|
||||
function scoreProperty(need: Need, prop: Property, overrideArea?: number, overridePrice?: number, overrideResultType?: string): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
}
|
||||
|
||||
function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
const MIN_SCORE = 22
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
// Multi-unit property: score against aggregate area (tenants renting the whole floor/building)
|
||||
const propScore = computeScore(prop, need, prop.areaSqm, prop.rentPricePerSqm)
|
||||
if (propScore !== null && propScore >= 25) {
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
matchStore.push(buildMatch(
|
||||
prop, undefined, need, propScore,
|
||||
prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id,
|
||||
isGoodLoc, `${prop.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
// Whole-property match (multi-unit building)
|
||||
const output = scoreProperty(need, prop)
|
||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||
}
|
||||
|
||||
// Unit-level pre-market: generate per released unit (for tenants seeking that specific unit size)
|
||||
// Per released unit (pre-market)
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitScore = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, true)
|
||||
if (unitScore === null || unitScore < 25) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
matchStore.push(buildMatch(
|
||||
prop, unit.id, need, unitScore,
|
||||
'FUTURE_AVAILABILITY', resultId,
|
||||
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
}
|
||||
} else {
|
||||
// No explicit units: use getEffectiveUnits (whole property synthesised as one unit)
|
||||
// Single-unit / synthesised units
|
||||
for (const unit of getEffectiveUnits(prop)) {
|
||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
||||
const score = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, isPreMarket)
|
||||
if (score === null || score < 25) continue
|
||||
|
||||
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||
? 'FUTURE_AVAILABILITY'
|
||||
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
||||
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
|
||||
const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
|
||||
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
|
||||
if (output.excluded || output.finalScore < MIN_SCORE) continue
|
||||
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
|
||||
matchStore.push(buildMatch(
|
||||
prop, undefined, need, score,
|
||||
effectiveResultType, resultId,
|
||||
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +136,22 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
async update(id, data: UpdateNeedInput) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
// Remove old matches and recompute with updated weights
|
||||
const updated = store[idx]
|
||||
const startLen = matchStore.length
|
||||
for (let i = startLen - 1; i >= 0; i--) {
|
||||
if (matchStore[i].needId === id) matchStore.splice(i, 1)
|
||||
}
|
||||
generateSyntheticMatches(updated)
|
||||
return updated
|
||||
},
|
||||
async remove(id) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
store.splice(idx, 1)
|
||||
},
|
||||
}
|
||||
|
||||
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
||||
for (const need of store) {
|
||||
generateSyntheticMatches(need)
|
||||
}
|
||||
|
||||
@@ -178,10 +178,13 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
let budgetRange: { maxPerSqm: number; currency: string } | undefined
|
||||
let budgetConfidence = 0.20
|
||||
if (budgetPerSqmMatch) {
|
||||
budgetRange = { maxPerSqm: parseInt(budgetPerSqmMatch[1]), currency: 'CHF' }
|
||||
const raw = parseInt(budgetPerSqmMatch[1])
|
||||
// Values < 100 are monthly (e.g. CHF 45/m²/month); convert to annual
|
||||
budgetRange = { maxPerSqm: raw < 100 ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.92
|
||||
} else if (budgetMaxMatch) {
|
||||
budgetRange = { maxPerSqm: parseInt(budgetMaxMatch[1]), currency: 'CHF' }
|
||||
const raw = parseInt(budgetMaxMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < 100 ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.60
|
||||
}
|
||||
|
||||
@@ -287,7 +290,7 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
|
||||
targetField: 'budgetRange',
|
||||
reason: 'Kein Budget erkannt.',
|
||||
suggestedAnswerOptions: ['< CHF 20/m²', 'CHF 20–40/m²', 'CHF 40–80/m²', '> CHF 80/m²', 'Flexible'],
|
||||
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
||||
importance: 'recommended',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user