feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore exclusively — allFactors always populated, no more dual-system (computeScore removed), weights from weightingProfile reflected in every breakdown - ScoreInlineBreakdown: new component shows hard/soft criteria with importance labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards - MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel - MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability' - aiService budget parser: values < 100 treated as monthly and multiplied by 12 to produce correct annual CHF/m²/year value — fixes 0-result searches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
|
|||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 } },
|
||||||
|
|||||||
@@ -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')} m²`} />
|
||||||
|
{minLettable != null && <KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')} m²`} />}
|
||||||
|
{property.contractDurationMonths != null && <KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />}
|
||||||
|
{(property.floorLevel != null || matchedUnit) && (
|
||||||
|
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL((matchedUnit?.floorLevel ?? property.floorLevel)!)} />
|
||||||
|
)}
|
||||||
|
{property.currentTenant && <KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />}
|
||||||
|
{property.leaseEndDate && <KeyFactRow label="Mietvertragsende" value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })} />}
|
||||||
|
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
|
||||||
|
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
|
||||||
|
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Eigenschaften */}
|
||||||
|
{property.softFactors && (
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<TrendingUp size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{property.softFactors.publicTransportMinutes != null && (
|
||||||
|
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||||
|
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||||
|
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||||
|
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
{property.softFactors.passerbyFrequency && (
|
||||||
|
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
|
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wegzeit */}
|
||||||
|
{property.softFactors?.publicTransportMinutes != null && (
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Clock size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||||
|
<Train size={18} color="#1d4ed8" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Nächster ÖV-Anschluss — {property.location.city}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
Die Zeiten beziehen sich auf die Strecke zu Fuss.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Einheiten */}
|
||||||
|
{units.length > 0 && (
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<Layers size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||||
|
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
|
||||||
|
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{preMarketUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId || preMarketUnits.length === 1} />)}
|
||||||
|
{otherUnits.map(u => <UnitRow key={u.id} unit={u} highlighted={u.id === match.unitId} />)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Beschreibung */}
|
||||||
|
{property.description && (
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Building2 size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{property.description}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quelle & Referenz */}
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Info size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||||
|
</Box>
|
||||||
|
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||||
|
{property.propertyNumber && <KeyFactRow label="Objektnummer" value={property.propertyNumber} />}
|
||||||
|
{property.importedFrom && <KeyFactRow label="Importiert aus" value={property.importedFrom} />}
|
||||||
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
|
)}
|
||||||
|
{property.sourceUrl && (
|
||||||
|
<Box sx={{ mt: 1.25 }}>
|
||||||
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
|
||||||
|
Zum Originalinserat
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{reasons.length > 0 && (
|
{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>
|
||||||
|
|||||||
@@ -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')} m²`} />
|
||||||
|
{minLettable != null && (
|
||||||
|
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')} m²`} />
|
||||||
|
)}
|
||||||
|
{property.contractDurationMonths != null && (
|
||||||
|
<KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />
|
||||||
|
)}
|
||||||
|
{property.floorLevel != null && (
|
||||||
|
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL(property.floorLevel)} />
|
||||||
|
)}
|
||||||
|
{property.currentTenant && (
|
||||||
|
<KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />
|
||||||
|
)}
|
||||||
|
{property.leaseEndDate && (
|
||||||
|
<KeyFactRow
|
||||||
|
label="Mietvertragsende"
|
||||||
|
value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.breakoutOption && (
|
||||||
|
<KeyFactRow
|
||||||
|
label="Break-out Option"
|
||||||
|
value={
|
||||||
|
property.breakoutOptionDate
|
||||||
|
? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
|
||||||
|
: 'Ja'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.riskLevel && (
|
||||||
|
<KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />
|
||||||
|
)}
|
||||||
|
{property.expansionPotentialSqm != null && (
|
||||||
|
<KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* ── Eigenschaften ── */}
|
||||||
|
{property.softFactors && (
|
||||||
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<TrendingUp size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{property.softFactors.publicTransportMinutes != null && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<Train size={11} />}
|
||||||
|
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
|
||||||
|
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${property.softFactors.parkingSpots} Parkplätze`}
|
||||||
|
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Prestigestandort"
|
||||||
|
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Hohe Sichtbarkeit"
|
||||||
|
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.passerbyFrequency && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
|
||||||
|
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Hoher Talentzugang"
|
||||||
|
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Talentindex: ${property.softFactors.talentAccess}`}
|
||||||
|
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Wegzeit ── */}
|
||||||
|
{property.softFactors?.publicTransportMinutes != null && (
|
||||||
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Clock size={15} color="#374151" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||||
|
<Train size={18} color="#1d4ed8" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{property.softFactors.publicTransportMinutes} Min. zu Fuss
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Nächster ÖV-Anschluss — {property.location.city}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{property.softFactors.infrastructureNotes && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
|
||||||
|
{property.softFactors.infrastructureNotes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
Die Zeiten beziehen sich auf die Strecke zu Fuss.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Einheiten ── */}
|
||||||
{(property.units ?? []).length > 0 && (
|
{(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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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')} m²`, 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')} m²`, 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')} m²`, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 20–40/m²', 'CHF 40–80/m²', '> CHF 80/m²', 'Flexible'],
|
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
||||||
importance: 'recommended',
|
importance: 'recommended',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user