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 { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar' import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState' import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import type { MatchCardViewModel } from './MatchCardViewModel' import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props { interface Props {
@@ -82,6 +83,13 @@ export function MatchCardCompact({ vm }: Props) {
<Divider sx={{ mb: 1.25 }} /> <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 */} {/* Top reason only */}
{vm.reasons.length > 0 && ( {vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary' import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar' import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState' import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import type { MatchCardViewModel } from './MatchCardViewModel' import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props { interface Props {
@@ -59,6 +60,18 @@ export function MatchCardExpanded({ vm }: Props) {
<Divider sx={{ mb: 2 }} /> <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 */} {/* Why it matches — all 3 reasons */}
{vm.reasons.length > 0 && ( {vm.reasons.length > 0 && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
@@ -1,5 +1,5 @@
import type { ResultType } from '../../domain/enums' 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' import type { PropertyUnit } from '../../domain/property'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini' export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -76,6 +76,14 @@ export interface MatchCardViewModel {
preMarketUnit?: PropertyUnit // specific unit being released pre-market preMarketUnit?: PropertyUnit // specific unit being released pre-market
preMarketAllUnits?: PropertyUnit[] // all units of the backing property preMarketAllUnits?: PropertyUnit[] // all units of the backing property
// Score transparency
scoreBreakdown?: {
hardMatchScore: number
softFactorScore: number
totalScore: number
}
allFactors?: ScoreFactor[]
// States // States
isSelected?: boolean isSelected?: boolean
isCompareSelected?: 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 { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, ShieldCheck, X } from 'lucide-react' import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
import type { Match } from '../../domain/match' import { ExternalLink } from 'lucide-react'
import type { Match, ScoreFactor } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
// ── Shared helpers ───────────────────────────────────────────────────────────── // ── Shared helpers ─────────────────────────────────────────────────────────────
@@ -9,49 +10,205 @@ function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? '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> = { const CREDIBILITY_LABELS: Record<string, string> = {
HIGH: 'Hohe Quellenqualität', HIGH: 'Hohe Quellenqualität',
MEDIUM: 'Mittlere Quellenqualität', MEDIUM: 'Mittlere Quellenqualität',
LOW: 'Niedrige Quellenqualität', LOW: 'Niedrige Quellenqualität',
} }
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ────────────── const CRITERION_LABEL: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
interface BreakdownRowProps { prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
label: string flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
value: number talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld',
max: number
description: string
color?: 'success' | 'warning' | 'error' | 'primary'
modifier?: boolean
} }
function BreakdownRow({ label, value, max, description, color = 'primary', modifier }: BreakdownRowProps) { const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
const pct = Math.round((Math.abs(value) / max) * 100)
const isNegative = modifier && value < 0 function factorLabel(criterion: string): string {
const isPositive = modifier && value > 0 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 ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{label}</Typography> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography <Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
variant="body2" <Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
sx={{ fontWeight: 700, color: isNegative ? '#c0392b' : isPositive ? '#1a7a4a' : 'text.primary' }} {imp.label}
> </Typography>
{modifier && value > 0 ? '+' : ''}{modifier ? value : `${value}/${max}`} <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> </Typography>
</Box> </Box>
{!modifier && (
<LinearProgress {/* Soft factors: full list when allFactors present, otherwise summary row */}
variant="determinate" {!hasAllFactors && (
value={Math.min(pct, 100)} <Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
sx={{ height: 8, borderRadius: 4, mb: 0.5 }} <Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
color={color} 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 probPct = signal ? Math.round(signal.probability * 100) : null
const credLabel = signal ? (CREDIBILITY_LABELS[signal.source.credibility] ?? signal.source.credibility) : null const credLabel = signal ? (CREDIBILITY_LABELS[signal.source.credibility] ?? signal.source.credibility) : null
// Approximate signal deduction for display purposes
const signalDeduction = signal && !isVerifiedContract const signalDeduction = signal && !isVerifiedContract
? Math.round((1 - signal.probability) * sb.hardMatchScore * 0.18) ? Math.round((1 - signal.probability) * sb.hardMatchScore * 0.18)
: 0 : 0
// Use allFactors or fall back to pos + neg
const displayFactors = match.allFactors
? match.allFactors
: [...match.positiveFactors, ...(match.negativeFactors ?? [])]
return ( return (
<> <>
{/* Schritt 1 — Kriterien-Match */} {/* Schritt 1 — Kriterien-Match */}
@@ -84,47 +245,32 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
Wie gut passt die Fläche, wenn das Signal eintrifft? Wie gut passt die Fläche, wenn das Signal eintrifft?
</Typography> </Typography>
{match.positiveFactors.map((f, i) => ( {displayFactors.map((f, i) => {
<Box key={i} sx={{ mb: 1.25 }}> const isPositive = f.score >= 70
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}> return (
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} /> <Box key={i} sx={{ mb: 1.25 }}>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography> {isPositive
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1a7a4a', ml: 1 }}>{f.score}/100</Typography> ? <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> </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> </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 }}> <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> <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="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography <Typography
variant="h4" variant="h4"
sx={{ sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
fontWeight: 800,
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
}}
> >
{sb.totalScore}/100 {sb.totalScore}/100
</Typography> </Typography>
@@ -210,8 +353,6 @@ interface Props {
} }
export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: Props) { export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: Props) {
const sb = match.scoreBreakdown
if (isFuture) { if (isFuture) {
return ( return (
<Paper sx={{ p: 2.5, mb: 2 }}> <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 ( return (
<Paper sx={{ p: 2.5, mb: 2 }}> <Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Score Breakdown</Typography> <Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Bewertungsherleitung</Typography>
<StandardBreakdown match={match} taxCalculatorUrl={taxCalculatorUrl} />
<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>
</Paper> </Paper>
) )
} }
+1
View File
@@ -101,6 +101,7 @@ export interface Match {
// Explainability // Explainability
positiveFactors: ScoreFactor[] positiveFactors: ScoreFactor[]
negativeFactors: ScoreFactor[] negativeFactors: ScoreFactor[]
allFactors?: ScoreFactor[] // all scored criteria (hard + soft) — for full score transparency
tradeoffs: TradeOff[] tradeoffs: TradeOff[]
tradeOffs?: TradeOff[] // alias for F004 naming convention tradeOffs?: TradeOff[] // alias for F004 naming convention
risks?: Risk[] risks?: Risk[]
@@ -137,5 +137,11 @@ export function buildMatchCardViewModel(
unitId: match.unitId ?? unit?.id ?? signal?.unitId, unitId: match.unitId ?? unit?.id ?? signal?.unitId,
preMarketUnit: unit, preMarketUnit: unit,
preMarketAllUnits: property?.units, 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'], 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', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-2024-001', 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: [ 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' } }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], 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', 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: [ 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-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' } }, { 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'], 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', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-2021-007', 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: [ 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-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' } }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1568992687947-868a62a9f521?w=800&h=400&fit=crop'], 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', 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: [ 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-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' } }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'], 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', 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: [ 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-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' } }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], 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', 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: [ 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-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 } }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], 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', 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: [ 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-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' } }, { 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'], 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', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZG-2022-012', 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: [ 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-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' }, { 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'], 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', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-2021-013', 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: [ 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-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' }, { 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, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -581,7 +597,7 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.62, score: 0.62,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-04-10', lastVerifiedAt: '2025-04-10',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Mietpreis nicht bestätigt', 'Verfügbarkeit nicht verifiziert'], warnings: ['Mietpreis nicht bestätigt', 'Verfügbarkeit nicht verifiziert'],
@@ -591,10 +607,18 @@ export const mockProperties: Property[] = [
visibilityScore: 98, visibilityScore: 98,
passerbyFrequency: 'VERY_HIGH', passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 2, publicTransportMinutes: 2,
parkingSpots: 0,
}, },
contractDurationMonths: 60,
ancillaryCosts: 6.0,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-02-15T14:00:00Z',
updatedAt: '2025-04-10T09:00:00Z', updatedAt: '2025-04-10T09:00:00Z',
}, },
@@ -621,9 +645,17 @@ export const mockProperties: Property[] = [
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle nicht verifiziert'], 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, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1498049794561-7780e7231661?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-01T10:00:00Z',
updatedAt: '2025-03-20T15:00:00Z', updatedAt: '2025-03-20T15:00:00Z',
}, },
@@ -655,10 +687,19 @@ export const mockProperties: Property[] = [
prestige: 74, prestige: 74,
accessibility: 86, accessibility: 86,
publicTransportMinutes: 5, publicTransportMinutes: 5,
talentAccess: 76,
parkingSpots: 4,
}, },
contractDurationMonths: 60,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1556761175-b413da4baf72?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-12T11:00:00Z',
updatedAt: '2025-04-05T10:00:00Z', updatedAt: '2025-04-05T10:00:00Z',
}, },
@@ -690,10 +731,18 @@ export const mockProperties: Property[] = [
prestige: 42, prestige: 42,
accessibility: 88, accessibility: 88,
parkingSpots: 35, parkingSpots: 35,
publicTransportMinutes: 10,
}, },
contractDurationMonths: 48,
ancillaryCosts: 3.0,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1525498128493-380d1990a112?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-02-20T09:00:00Z',
updatedAt: '2025-03-28T12:00:00Z', updatedAt: '2025-03-28T12:00:00Z',
}, },
@@ -716,7 +765,7 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.61, score: 0.61,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['floorLevel', 'ancillaryCosts'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-04-18', lastVerifiedAt: '2025-04-18',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Mietpreis nicht final bestätigt'], warnings: ['Mietpreis nicht final bestätigt'],
@@ -726,10 +775,18 @@ export const mockProperties: Property[] = [
visibilityScore: 94, visibilityScore: 94,
passerbyFrequency: 'VERY_HIGH', passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 3, publicTransportMinutes: 3,
parkingSpots: 0,
}, },
contractDurationMonths: 60,
ancillaryCosts: 7.0,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1556742502-ec7c0e9f34b6?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-05T13:00:00Z',
updatedAt: '2025-04-18T11:00:00Z', updatedAt: '2025-04-18T11:00:00Z',
}, },
@@ -752,7 +809,7 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.57, score: 0.57,
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'], missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
missingOptionalFields: ['floorLevel'], missingOptionalFields: [],
lastVerifiedAt: '2025-04-02', lastVerifiedAt: '2025-04-02',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle', 'Renovierungsstand unklar'], warnings: ['Daten aus Drittquelle', 'Renovierungsstand unklar'],
@@ -761,10 +818,18 @@ export const mockProperties: Property[] = [
prestige: 62, prestige: 62,
accessibility: 78, accessibility: 78,
publicTransportMinutes: 8, publicTransportMinutes: 8,
parkingSpots: 6,
}, },
contractDurationMonths: 48,
ancillaryCosts: 4.5,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-02-28T10:00:00Z',
updatedAt: '2025-04-02T09:00:00Z', updatedAt: '2025-04-02T09:00:00Z',
}, },
@@ -792,9 +857,17 @@ export const mockProperties: Property[] = [
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Hallenhöhe nicht verifiziert', 'Kranbahn Status unklar'], 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, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1572021335469-31706a17aaef?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-10T08:00:00Z',
updatedAt: '2025-03-25T14:00:00Z', updatedAt: '2025-03-25T14:00:00Z',
}, },
@@ -826,10 +899,18 @@ export const mockProperties: Property[] = [
prestige: 68, prestige: 68,
accessibility: 82, accessibility: 82,
publicTransportMinutes: 9, publicTransportMinutes: 9,
parkingSpots: 10,
}, },
contractDurationMonths: 60,
ancillaryCosts: 5.5,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-18T09:00:00Z',
updatedAt: '2025-04-12T11:00:00Z', updatedAt: '2025-04-12T11:00:00Z',
}, },
@@ -852,7 +933,7 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.59, score: 0.59,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-04-08', lastVerifiedAt: '2025-04-08',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Prix non confirmé', 'Disponibilité à vérifier'], warnings: ['Prix non confirmé', 'Disponibilité à vérifier'],
@@ -862,10 +943,18 @@ export const mockProperties: Property[] = [
visibilityScore: 96, visibilityScore: 96,
passerbyFrequency: 'VERY_HIGH', passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 3, publicTransportMinutes: 3,
parkingSpots: 0,
}, },
contractDurationMonths: 60,
ancillaryCosts: 6.5,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-02-10T10:00:00Z',
updatedAt: '2025-04-08T09:00:00Z', updatedAt: '2025-04-08T09:00:00Z',
}, },
@@ -888,7 +977,7 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.58, score: 0.58,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-04-14', lastVerifiedAt: '2025-04-14',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle', 'Ausbauqualität nicht bestätigt'], warnings: ['Daten aus Drittquelle', 'Ausbauqualität nicht bestätigt'],
@@ -897,10 +986,18 @@ export const mockProperties: Property[] = [
prestige: 66, prestige: 66,
accessibility: 80, accessibility: 80,
publicTransportMinutes: 6, publicTransportMinutes: 6,
parkingSpots: 8,
}, },
contractDurationMonths: 48,
ancillaryCosts: 4.5,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-08T08:00:00Z',
updatedAt: '2025-04-14T10: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'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'],
}, },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-05-03T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z', updatedAt: '2025-05-10T08:00:00Z',
}, },
@@ -954,7 +1052,9 @@ export const mockProperties: Property[] = [
freshness: DataFreshness.FRESH, freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'],
}, },
softFactors: { accessibility: 76, publicTransportMinutes: 8 },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-04-14T08:00:00Z',
updatedAt: '2025-05-10T08: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'], warnings: ['Baubewilligung erteilt, Mieter noch nicht bekannt', 'Konditionen geschätzt'],
}, },
riskLevel: RiskLevel.MEDIUM, 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', createdAt: '2025-01-20T09:00:00Z',
updatedAt: '2025-05-10T09: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'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Preis geschätzt'],
}, },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-04-02T08:00:00Z',
updatedAt: '2025-05-10T08: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'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Daten nicht verifiziert'],
}, },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-03-14T08:00:00Z',
updatedAt: '2025-05-09T09: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'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Konditionen unbekannt'],
}, },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-04-08T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z', updatedAt: '2025-05-10T08:00:00Z',
}, },
@@ -1087,9 +1191,16 @@ export const mockProperties: Property[] = [
warnings: ['Daten aus Drittquelle'], warnings: ['Daten aus Drittquelle'],
}, },
softFactors: { prestige: 76, accessibility: 88, visibilityScore: 62, talentAccess: 82, parkingSpots: 8, publicTransportMinutes: 4 }, softFactors: { prestige: 76, accessibility: 88, visibilityScore: 62, talentAccess: 82, parkingSpots: 8, publicTransportMinutes: 4 },
contractDurationMonths: 60,
ancillaryCosts: 4.5,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1542314831-068cd1dbfeeb?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-04-25T10:00:00Z',
updatedAt: '2025-05-10T09:00:00Z', updatedAt: '2025-05-10T09:00:00Z',
}, },
@@ -1118,9 +1229,16 @@ export const mockProperties: Property[] = [
warnings: ['Ausbaustandard nicht bestätigt'], warnings: ['Ausbaustandard nicht bestätigt'],
}, },
softFactors: { prestige: 74, accessibility: 86, visibilityScore: 60, talentAccess: 80, parkingSpots: 6, publicTransportMinutes: 5 }, softFactors: { prestige: 74, accessibility: 86, visibilityScore: 60, talentAccess: 80, parkingSpots: 6, publicTransportMinutes: 5 },
contractDurationMonths: 48,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1613545325278-f24b0cae1224?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-04-20T11:00:00Z',
updatedAt: '2025-05-08T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z',
}, },
@@ -1143,15 +1261,22 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.60, score: 0.60,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-05-05', lastVerifiedAt: '2025-05-05',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Mietpreis nicht final bestätigt'], 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, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-03-28T09:00:00Z',
updatedAt: '2025-05-05T10:00:00Z', updatedAt: '2025-05-05T10:00:00Z',
}, },
@@ -1174,15 +1299,22 @@ export const mockProperties: Property[] = [
dataQuality: { dataQuality: {
score: 0.56, score: 0.56,
missingCriticalFields: ['contractDurationMonths'], missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'], missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-04-22', lastVerifiedAt: '2025-04-22',
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle', 'Schaufensterfront nicht bestätigt'], 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, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1534398079543-7ae6d016b86a?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-04-08T10:00:00Z',
updatedAt: '2025-04-22T09: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'], warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'],
}, },
riskLevel: RiskLevel.HIGH, 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', createdAt: '2025-04-28T09:00:00Z',
updatedAt: '2025-05-15T10:00:00Z', updatedAt: '2025-05-15T10:00:00Z',
}, },
@@ -1235,10 +1368,17 @@ export const mockProperties: Property[] = [
freshness: DataFreshness.STALE, freshness: DataFreshness.STALE,
warnings: ['Hallenhöhe nicht bestätigt', 'Daten aus Drittquelle'], 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, riskLevel: RiskLevel.MEDIUM,
images: ['https://images.unsplash.com/photo-1590239926044-4131a46e3f27?w=800&h=400&fit=crop'], 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', 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', createdAt: '2025-04-15T08:00:00Z',
updatedAt: '2025-05-02T10: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'], 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', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-2024-037', 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: [ 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-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 } }, { 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 { 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 { useNavigate, useParams } from 'react-router'
import type { PropertyUnit } from '../../domain/property'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import { propertyService } from '../../services/propertyService' import { propertyService } from '../../services/propertyService'
@@ -27,13 +28,80 @@ import {
} from '../../components/match-detail' } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' 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 HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, 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[] { function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
@@ -273,6 +341,161 @@ export default function MatchDetail() {
<ExecutiveSummaryPanel match={match} /> <ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} /> <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 && ( {reasons.length > 0 && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography> <Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
+287 -10
View File
@@ -16,17 +16,60 @@ import {
Building2, Building2,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock,
ExternalLink,
Info,
Layers,
Mail, Mail,
MapPin, MapPin,
ShieldCheck, ShieldCheck,
Layers, Tag,
Train,
TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById } from '../../hooks/useProperties'
import type { PropertyUnit } from '../../domain/property' import type { PropertyUnit } from '../../domain/property'
// ── Helpers ───────────────────────────────────────────────────────────────────
const FLOOR_LABEL = (level: number) => const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG` 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 }) { function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
if (unit.schattenmarktRelease?.enabled) { if (unit.schattenmarktRelease?.enabled) {
return ( return (
@@ -85,6 +128,8 @@ function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boole
) )
} }
// ── Page ──────────────────────────────────────────────────────────────────────
export default function PropertyDetail() { export default function PropertyDetail() {
const { propertyId } = useParams<{ propertyId: string }>() const { propertyId } = useParams<{ propertyId: string }>()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -115,7 +160,19 @@ export default function PropertyDetail() {
const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled) const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = (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() { function handleSendInquiry() {
if (!inquiryName.trim() || !inquiryText.trim()) return if (!inquiryName.trim() || !inquiryText.trim()) return
@@ -124,7 +181,8 @@ export default function PropertyDetail() {
return ( return (
<Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}> <Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}>
{/* Back */}
{/* ── Back ── */}
<Button <Button
startIcon={<ArrowLeft size={15} />} startIcon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@@ -134,7 +192,7 @@ export default function PropertyDetail() {
Zurück zu den Ergebnissen Zurück zu den Ergebnissen
</Button> </Button>
{/* Header */} {/* ── Header ── */}
<Paper sx={{ mb: 2, overflow: 'hidden' }}> <Paper sx={{ mb: 2, overflow: 'hidden' }}>
{property.images?.[0] && ( {property.images?.[0] && (
<Box <Box
@@ -150,7 +208,7 @@ export default function PropertyDetail() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Building2 size={15} color="#7c3aed" /> <Building2 size={15} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}> <Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{property.assetType} {ASSET_LABELS[property.assetType] ?? property.assetType}
</Typography> </Typography>
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{property.title}</Typography> <Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{property.title}</Typography>
@@ -163,7 +221,7 @@ export default function PropertyDetail() {
</Box> </Box>
<Box sx={{ textAlign: 'right' }}> <Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f' }}> <Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
CHF {monthlyRentDisplay}/m²/Mt. CHF {monthlyPerSqm.toLocaleString('de-CH')}/m²/Mt.
</Typography> </Typography>
<Typography variant="caption" color="text.secondary">{property.areaSqm.toLocaleString('de-CH')} m² total</Typography> <Typography variant="caption" color="text.secondary">{property.areaSqm.toLocaleString('de-CH')} m² total</Typography>
</Box> </Box>
@@ -180,7 +238,178 @@ export default function PropertyDetail() {
</Box> </Box>
</Paper> </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 && ( {(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <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> <Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box> </Box>
{/* Column headers */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}> <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 => ( {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography> <Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
@@ -204,7 +432,56 @@ export default function PropertyDetail() {
</Paper> </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 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" /> <Mail size={15} color="#374151" />
@@ -242,7 +519,7 @@ export default function PropertyDetail() {
? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit') ? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: property.title : property.title
} }
InputProps={{ readOnly: true }} slotProps={{ input: { readOnly: true } }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }} sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/> />
</Box> </Box>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { IMatchProvider, MatchFilters } from './IMatchProvider' import type { IMatchProvider, MatchFilters } from './IMatchProvider'
import type { Match } from '../domain/match' 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 const store = matchStore
export const MockupMatchProvider: IMatchProvider = { 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 { mockNeeds } from '../mock-data/needs'
import { matchStore } from './MockupMatchProvider' import { matchStore } from './MockupMatchProvider'
import { propertyStore } from './MockupPropertyProvider' 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 { Match } from '../domain/match'
import type { MatchEngineOutput } from '../domain/scoring'
import type { Property } from '../domain/property'
import { getEffectiveUnits } from '../domain/property' import { getEffectiveUnits } from '../domain/property'
import { calculateScore } from '../features/matching/scoreCalculator'
const store: Need[] = [...mockNeeds] const store: Need[] = [...mockNeeds]
// ── Location scoring ─────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
const CANTON_MAP: Record<string, string> = { function strengthFromScore(s: number): MatchStrength {
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 {
if (s >= 75) return MatchStrength.STRONG if (s >= 75) return MatchStrength.STRONG
if (s >= 55) return MatchStrength.MODERATE if (s >= 55) return MatchStrength.MODERATE
return MatchStrength.WEAK return MatchStrength.WEAK
} }
function buildMatch( function buildMatch(
prop: typeof propertyStore[0], prop: Property,
unitId: string | undefined, unitId: string | undefined,
need: Need, need: Need,
score: number, output: MatchEngineOutput,
effectiveResultType: string, effectiveResultType: string,
resultId: string, resultId: string,
isGoodLoc: boolean,
areaLabel: string,
now: string, now: string,
): Match { ): Match {
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
propertyId: prop.id, propertyId: prop.id,
@@ -99,27 +39,22 @@ function buildMatch(
needId: need.id, needId: need.id,
resultId, resultId,
resultType: effectiveResultType as Match['resultType'], resultType: effectiveResultType as Match['resultType'],
matchScore: score, matchScore: output.finalScore,
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength], matchStrength: strengthFromScore(output.finalScore),
status: MatchStatus.PENDING_REVIEW, status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { scoreBreakdown: {
hardMatchScore: score + 5, hardMatchScore: output.hardMatchScore,
softFactorScore: score - 5, softFactorScore: output.softFactorScore,
confidenceModifier: isGoodLoc ? 0.96 : 0.82, confidenceModifier: output.confidenceModifier,
dataQualityModifier: 0.92, dataQualityModifier: output.dataQualityModifier,
totalScore: score, totalScore: output.finalScore,
}, },
positiveFactors: isGoodLoc positiveFactors: output.positiveFactors,
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} bevorzugter Standort` }] negativeFactors: output.negativeFactors,
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${areaLabel} verfügbar` }], allFactors: [...output.allHardFactors, ...output.allSoftFactors],
negativeFactors: !isGoodLoc tradeoffs: output.tradeOffs ?? [],
? [{ 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 }]
: [],
explainabilitySummary: isGoodLoc 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.`, : `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
confidenceLevel: isGoodLoc ? 0.88 : 0.60, confidenceLevel: isGoodLoc ? 0.88 : 0.60,
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM, 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) { function generateSyntheticMatches(need: Need) {
const now = new Date().toISOString() const now = new Date().toISOString()
const MIN_SCORE = 22
for (const prop of propertyStore) { for (const prop of propertyStore) {
const hasExplicitUnits = (prop.units ?? []).length > 0 const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) { if (hasExplicitUnits) {
// Multi-unit property: score against aggregate area (tenants renting the whole floor/building) // Whole-property match (multi-unit building)
const propScore = computeScore(prop, need, prop.areaSqm, prop.rentPricePerSqm) const output = scoreProperty(need, prop)
if (propScore !== null && propScore >= 25) { if (!output.excluded && output.finalScore >= MIN_SCORE) {
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9 matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
matchStore.push(buildMatch(
prop, undefined, need, propScore,
prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id,
isGoodLoc, `${prop.areaSqm.toLocaleString('de-CH')}`, 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!) { for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue if (!unit.schattenmarktRelease?.enabled) continue
const unitScore = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, true) const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
if (unitScore === null || unitScore < 25) continue if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
const resultId = `schattenmarkt-${prop.id}-${unit.id}` const resultId = `schattenmarkt-${prop.id}-${unit.id}`
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9 matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
matchStore.push(buildMatch(
prop, unit.id, need, unitScore,
'FUTURE_AVAILABILITY', resultId,
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')}`, now,
))
} }
} else { } else {
// No explicit units: use getEffectiveUnits (whole property synthesised as one unit) // Single-unit / synthesised units
for (const unit of getEffectiveUnits(prop)) { for (const unit of getEffectiveUnits(prop)) {
const isPreMarket = unit.schattenmarktRelease?.enabled === true const isPreMarket = unit.schattenmarktRelease?.enabled === true
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY' const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
const score = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, isPreMarket) const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
if (score === null || score < 25) continue const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
if (output.excluded || output.finalScore < MIN_SCORE) continue
const effectiveResultType = (isPreMarket || isFutureProp)
? 'FUTURE_AVAILABILITY'
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9 matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
matchStore.push(buildMatch(
prop, undefined, need, score,
effectiveResultType, resultId,
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')}`, now,
))
} }
} }
} }
@@ -207,10 +136,22 @@ export const MockupNeedProvider: INeedProvider = {
async update(id, data: UpdateNeedInput) { async update(id, data: UpdateNeedInput) {
const idx = store.findIndex(n => n.id === id) const idx = store.findIndex(n => n.id === id)
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() } 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) { async remove(id) {
const idx = store.findIndex(n => n.id === id) const idx = store.findIndex(n => n.id === id)
store.splice(idx, 1) 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 budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20 let budgetConfidence = 0.20
if (budgetPerSqmMatch) { 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 budgetConfidence = 0.92
} else if (budgetMaxMatch) { } 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 budgetConfidence = 0.60
} }
@@ -287,7 +290,7 @@ function mockParseNeed(input: string): ParseNeedResult {
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?', questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange', targetField: 'budgetRange',
reason: 'Kein Budget erkannt.', 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', importance: 'recommended',
}) })
} }