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