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:
Benjamin Sutter
2026-05-21 20:41:46 +02:00
parent 34a4dcfb29
commit 27c53f3af1
13 changed files with 1126 additions and 299 deletions
@@ -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,49 +10,205 @@ 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>
<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={factor.score}
color={color}
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
/>
<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>
{!modifier && (
<LinearProgress
variant="determinate"
value={Math.min(pct, 100)}
sx={{ height: 8, borderRadius: 4, mb: 0.5 }}
color={color}
/>
{/* 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>
)}
<Typography variant="caption" color="text.secondary">{description}</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>
</>
)
}
@@ -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,47 +245,32 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
Wie gut passt die Fläche, wenn das Signal eintrifft?
</Typography>
{match.positiveFactors.map((f, i) => (
<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 }} />
<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>
{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 }}>
{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' }}>{factorLabel(f.criterion)}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={f.score}
color={scoreColor(f.score)}
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>
<LinearProgress
variant="determinate"
value={f.score}
color={scoreColor(f.score)}
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>
))}
{(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>
)
}
+1
View File
@@ -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
View File
@@ -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 } },
+225 -2
View File
@@ -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')}`} />
{minLettable != null && <KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />}
{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')}`} />}
</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>
+287 -10
View File
@@ -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')}`} />
{minLettable != null && (
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />
)}
{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')}`} />
)}
</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>
+2 -2
View File
@@ -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 = {
+63 -122
View File
@@ -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')}`, 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')}`, 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')}`, 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)
}
+6 -3
View File
@@ -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 2040/m²', 'CHF 4080/m²', '> CHF 80/m²', 'Flexible'],
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300420/m²/J', 'CHF 420540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
importance: 'recommended',
})
}