diff --git a/src/App.tsx b/src/App.tsx
index afa83c4..9832131 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -34,6 +34,7 @@ const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
const Compare = lazy(() => import('./pages/demand/Compare'))
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
+const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
@@ -71,6 +72,7 @@ function App() {
} />
} />
} />
+ } />
diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx
index 5b4b2b7..1453e0a 100644
--- a/src/components/match-card/IntelligenceMatchCard.tsx
+++ b/src/components/match-card/IntelligenceMatchCard.tsx
@@ -12,10 +12,17 @@ import {
ShieldCheck,
User,
} from 'lucide-react'
+import { useNavigate } from 'react-router'
import { LocationPreview } from '../shared/LocationPreview'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import type { MatchCardViewModel } from './MatchCardViewModel'
+function floorLabel(level: number): string {
+ if (level === 0) return 'EG'
+ if (level < 0) return `UG ${Math.abs(level)}`
+ return `${level}.OG`
+}
+
// ── Constants ─────────────────────────────────────────────────────────────────
const RESULT_TYPE_META: Record = {
@@ -81,6 +88,7 @@ function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | u
function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false
+ const navigate = useNavigate()
// PRE-MARKET VERIFIED: soft purple / institutional premium
// MARKET SIGNAL: slate blue / analytical
@@ -207,6 +215,28 @@ function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
)}
+ {/* PRE-MARKET VERIFIED: specific unit info */}
+ {isControlled && vm.preMarketUnit && (
+
+
+ Freigegebene Einheit
+
+
+
+ {floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
+
+
+ {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
+
+ {vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
+
+ ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
+
+ )}
+
+
+ )}
+
{/* MARKET SIGNAL: market indicators */}
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
@@ -246,7 +276,7 @@ function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
)}
{/* Actions */}
-
+
{vm.actions.map(a => (
{/* Disclaimer footnote */}
diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts
index 86d7400..ead58ec 100644
--- a/src/components/match-card/MatchCardViewModel.ts
+++ b/src/components/match-card/MatchCardViewModel.ts
@@ -1,5 +1,6 @@
import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem } from '../../domain/match'
+import type { PropertyUnit } from '../../domain/property'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -66,6 +67,12 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean
signalAreaSqmEstimate?: number
+ // Property / unit reference (for FUTURE_AVAILABILITY with a backing property)
+ propertyId?: string
+ unitId?: string
+ preMarketUnit?: PropertyUnit // specific unit being released pre-market
+ preMarketAllUnits?: PropertyUnit[] // all units of the backing property
+
// States
isSelected?: boolean
isCompareSelected?: boolean
diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx
index c99289f..1ea10ca 100644
--- a/src/components/supply/PropertyDetailView.tsx
+++ b/src/components/supply/PropertyDetailView.tsx
@@ -25,6 +25,7 @@ import { PropertyMap } from '../shared'
import { NeedMatchCard } from './NeedMatchCard'
import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
import type { Property, PropertyUnit, UnitNeedMatch, UpdatePropertyInput } from '../../domain/property'
+import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { unitMatchService } from '../../services/unitMatchService'
import type { PropertyNeedMatch } from '../../domain/match'
import { usePropertyById } from '../../hooks/useProperties'
@@ -317,6 +318,17 @@ function PreMarketPanel({ p }: { p: Property }) {
const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false)
const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6)
const [saving, setSaving] = useState(false)
+ const [unitStates, setUnitStates] = useState>(() => {
+ const init: Record = {}
+ for (const u of p.units ?? []) {
+ init[u.id] = {
+ enabled: u.schattenmarktRelease?.enabled ?? false,
+ availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '',
+ }
+ }
+ return init
+ })
+ const [unitSaving, setUnitSaving] = useState>({})
const queryClient = useQueryClient()
const showToast = useToastStore(s => s.showToast)
@@ -369,6 +381,21 @@ function PreMarketPanel({ p }: { p: Property }) {
if (enabled) save(enabled, months)
}
+ async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string) {
+ setUnitSaving(prev => ({ ...prev, [unitId]: true }))
+ try {
+ await MockupUnitProvider.update(unitId, {
+ schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined },
+ })
+ await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
+ await queryClient.invalidateQueries({ queryKey: ['properties'] })
+ } catch {
+ showToast('Fehler beim Speichern der Einheit.', 'error')
+ } finally {
+ setUnitSaving(prev => ({ ...prev, [unitId]: false }))
+ }
+ }
+
return (
<>
@@ -459,6 +486,68 @@ function PreMarketPanel({ p }: { p: Property }) {
+ {/* Unit-level release controls */}
+ {(p.units?.length ?? 0) > 0 && (
+
+
+ Einheiten freigeben
+
+ {p.units!.map(u => {
+ const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
+ return (
+
+
+
+ {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
+
+
+ {u.areaSqm.toLocaleString('de-CH')} m²
+ {u.currentTenant ? ` · ${u.currentTenant}` : ''}
+
+
+ {
+ const next = { ...us, availableFrom: e.target.value }
+ setUnitStates(prev => ({ ...prev, [u.id]: next }))
+ if (us.enabled) saveUnit(u.id, true, e.target.value)
+ }}
+ />
+
+ {unitSaving[u.id] && }
+ {
+ const next = { ...us, enabled: checked }
+ setUnitStates(prev => ({ ...prev, [u.id]: next }))
+ saveUnit(u.id, checked, us.availableFrom)
+ }}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
+ }}
+ />
+
+
+ )
+ })}
+
+ )}
+
{/* Demand Intelligence */}
diff --git a/src/domain/futureSignal.ts b/src/domain/futureSignal.ts
index f2cdf00..ad896f3 100644
--- a/src/domain/futureSignal.ts
+++ b/src/domain/futureSignal.ts
@@ -55,4 +55,7 @@ export interface FutureSignal {
// DEMAND = company is looking for space → shown as Markt-Lead in Verwaltung
// undefined = treated as SUPPLY (backwards compat for generated signals)
signalDirection?: 'SUPPLY' | 'DEMAND'
+
+ // Unit-level reference: if set, this signal is about a specific rentable unit
+ unitId?: string
}
diff --git a/src/domain/property.ts b/src/domain/property.ts
index d61ed33..242c9dd 100644
--- a/src/domain/property.ts
+++ b/src/domain/property.ts
@@ -86,6 +86,7 @@ export interface SoftFactors {
export interface PropertyUnit {
id: string
+ propertyId?: string // FK to parent Property (set when stored independently)
floorLevel: number // 0=EG, 1=1.OG, -1=UG
unitLabel?: string // e.g. "Ost", "West", "Einheit A"
areaSqm: number
@@ -98,6 +99,11 @@ export interface PropertyUnit {
isFlexible?: boolean // can be partially leased (Teilfläche)
minLettableSqm?: number // minimum area that can be leased standalone
offeredSqm?: number // currently offered area (≤ areaSqm); undefined = full unit
+ // Unit-level pre-market release
+ schattenmarktRelease?: {
+ enabled: boolean
+ availableFrom?: string // ISO date; overrides property-level leaseEndDate for this unit
+ }
}
// A bundle groups multiple free units for a combined offer
diff --git a/src/domain/unifiedResult.ts b/src/domain/unifiedResult.ts
index 99e9c0b..c8fc8a5 100644
--- a/src/domain/unifiedResult.ts
+++ b/src/domain/unifiedResult.ts
@@ -1,5 +1,5 @@
import type { ResultType } from './enums'
-import type { Property } from './property'
+import type { Property, PropertyUnit } from './property'
import type { FutureSignal } from './futureSignal'
import type { Match } from './match'
@@ -30,6 +30,8 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
resultType: 'FUTURE_AVAILABILITY'
signal: FutureSignal
match: Match
+ property?: Property // backing property (when signal has a propertyId)
+ unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
}
export type UnifiedMatchResult =
diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts
index 5c5048a..34777a7 100644
--- a/src/features/matching/matchCardAdapter.ts
+++ b/src/features/matching/matchCardAdapter.ts
@@ -36,10 +36,11 @@ export function buildMatchCardViewModel(
const isFuture = resultType === 'FUTURE_AVAILABILITY'
const property = !isFuture
? (result as VerifiedPortfolioResult | ExternalMarketResult).property
- : undefined
+ : (result as FutureAvailabilityResult).property
const signal = isFuture
? (result as FutureAvailabilityResult).signal
: undefined
+ const unit = isFuture ? (result as FutureAvailabilityResult).unit : undefined
const city = property?.location?.city
const district = property?.location?.district
@@ -47,6 +48,9 @@ export function buildMatchCardViewModel(
? `${city}${district ? `, ${district}` : ''}`
: signal?.locationHint ?? '–'
+ // For PRE-MARKET: title comes from unit if available, then signal, then property
+
+
const availabilityLabel =
property?.availabilityDate ??
(signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
@@ -69,8 +73,8 @@ export function buildMatchCardViewModel(
return {
id: result.matchId,
title:
- property?.title ??
signal?.title ??
+ property?.title ??
signal?.companyName ??
signal?.locationHint ??
'–',
@@ -116,5 +120,9 @@ export function buildMatchCardViewModel(
signalConfirmedFacts: signal?.confirmedFacts,
signalUnconfirmedFacts: signal?.unconfirmedFacts,
signalAreaSqmEstimate: signal?.areaSqmEstimate,
+ propertyId: property?.id ?? signal?.propertyId,
+ unitId: unit?.id ?? signal?.unitId,
+ preMarketUnit: unit,
+ preMarketAllUnits: property?.units,
}
}
diff --git a/src/hooks/useSchattenmarktSignals.ts b/src/hooks/useSchattenmarktSignals.ts
index 26c0011..7b1bca2 100644
--- a/src/hooks/useSchattenmarktSignals.ts
+++ b/src/hooks/useSchattenmarktSignals.ts
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
-import type { Property } from '../domain/property'
+import type { Property, PropertyUnit } from '../domain/property'
import type { FutureSignal } from '../domain/futureSignal'
import { SignalType, RiskLevel, ResultType } from '../domain/enums'
@@ -11,16 +11,38 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
const signals: FutureSignal[] = []
for (const p of properties) {
if (p.resultType !== ResultType.VERIFIED_PORTFOLIO) continue
+
const rel = p.schattenmarktRelease
if (!rel?.enabled) continue
- const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths)
- if (!triggerDate || MOCK_TODAY < triggerDate) continue
- signals.push(buildSignal(p))
+
+ // Unit-level: generate one signal per released unit that has schattenmarktRelease.enabled
+ const releasedUnits = (p.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
+
+ if (releasedUnits.length > 0) {
+ for (const unit of releasedUnits) {
+ const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
+ if (!availableFrom) continue
+ const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
+ if (MOCK_TODAY < triggerDate) continue
+ signals.push(buildUnitSignal(p, unit, availableFrom))
+ }
+ } else {
+ // Backward compat: property-level signal (no explicit unit releases defined)
+ const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths)
+ if (!triggerDate || MOCK_TODAY < triggerDate) continue
+ signals.push(buildPropertySignal(p))
+ }
}
return signals
}, [properties])
}
+function getTriggerDate(availableFrom: string, leadTimeMonths: number): Date {
+ const d = new Date(availableFrom)
+ d.setMonth(d.getMonth() - leadTimeMonths)
+ return d
+}
+
function getEarliestTriggerDate(p: Property, leadTimeMonths: number): Date | null {
const candidates: Date[] = []
if (p.leaseEndDate) {
@@ -36,13 +58,61 @@ function getEarliestTriggerDate(p: Property, leadTimeMonths: number): Date | nul
return candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null
}
-function buildSignal(p: Property): FutureSignal {
+function buildUnitSignal(p: Property, unit: PropertyUnit, availableFrom: string): FutureSignal {
+ const targetDate = new Date(availableFrom)
+ const monthsUntil = Math.max(1, Math.round(
+ (targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30),
+ ))
+ const locationLabel = `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`
+ const monthName = targetDate.toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
+ const floorLabel = unit.floorLevel === 0 ? 'EG' : unit.floorLevel < 0 ? `UG ${Math.abs(unit.floorLevel)}` : `${unit.floorLevel}.OG`
+ const unitDesc = unit.unitLabel ? `${floorLabel} · ${unit.unitLabel}` : floorLabel
+
+ return {
+ id: `schattenmarkt-${p.id}-${unit.id}`,
+ signalType: SignalType.LEASE_EXPIRY,
+ propertyId: p.id,
+ unitId: unit.id,
+ title: `${p.title} · ${unitDesc} — frei ab ${monthName}`,
+ locationHint: locationLabel,
+ areaSqmEstimate: unit.areaSqm,
+ probability: 0.92,
+ confidenceScore: 0.92,
+ timeHorizonMonths: monthsUntil,
+ source: { type: 'LEASE_CONTRACT', credibility: 'HIGH' },
+ sensitivityLevel: 'INTERNAL',
+ disclaimer: 'Verwaltung hat diese Einheit für Pre-Market-Sichtbarkeit freigegeben. Vertragsende aus internem ERP bestätigt — höchste Signalqualität.',
+ riskLevel: RiskLevel.LOW,
+ relevanceScore: 0.92,
+ isVerified: true,
+ organizationId: p.organizationId,
+ createdAt: MOCK_TODAY.toISOString(),
+ updatedAt: MOCK_TODAY.toISOString(),
+ aiSummary: `Vertrag der ${unit.currentTenant ?? p.currentTenant ?? 'aktuellen Mietpartei'} läuft in ${monthsUntil} Monaten aus (${monthName}). Einheit: ${unit.areaSqm.toLocaleString('de-CH')} m² · ${unitDesc} · ${locationLabel}. Die Verwaltung hat diese Einheit explizit für den Markt freigegeben — vertraglich bestätigt.`,
+ marketIndicators: [
+ `Vertragsende ${monthName} aus Verwaltungssystem bestätigt`,
+ `Einheit ${unitDesc} · ${unit.areaSqm.toLocaleString('de-CH')} m² · ${locationLabel}`,
+ `Pre-Market-Freigabe durch Verwaltung erteilt`,
+ ],
+ confirmedFacts: [
+ `Vertragsende ${monthName} aus ERP bestätigt`,
+ `${unit.areaSqm.toLocaleString('de-CH')} m² · ${unitDesc} — bestätigt`,
+ `Standort ${locationLabel} — bestätigt`,
+ ],
+ unconfirmedFacts: [
+ 'Ob Nachmieter bereits bekannt',
+ 'Ob Umbaumassnahmen geplant',
+ ],
+ }
+}
+
+function buildPropertySignal(p: Property): FutureSignal {
const targetDate = p.breakoutOption && p.breakoutOptionDate
? new Date(p.breakoutOptionDate)
: p.leaseEndDate ? new Date(p.leaseEndDate) : new Date()
const monthsUntil = Math.max(1, Math.round(
- (targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30)
+ (targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30),
))
const locationLabel = `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`
diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts
index 59f5b28..db6e2b5 100644
--- a/src/hooks/useUnifiedResults.ts
+++ b/src/hooks/useUnifiedResults.ts
@@ -38,8 +38,10 @@ export function useUnifiedResults(needId?: string) {
if (match.resultType === 'FUTURE_AVAILABILITY') {
const signal = allSignals.find(s => s.id === refId || s.propertyId === refId)
if (!signal || signal.signalDirection === 'DEMAND') return []
+ const backingProperty = signal.propertyId ? properties.find(p => p.id === signal.propertyId) : undefined
+ const backingUnit = backingProperty?.units?.find(u => u.id === signal.unitId)
return [{ matchId: match.id, needId: match.needId, matchScore: match.matchScore,
- resultType: 'FUTURE_AVAILABILITY', signal, match }]
+ resultType: 'FUTURE_AVAILABILITY', signal, match, property: backingProperty, unit: backingUnit }]
}
const property = properties.find(p => p.id === refId)
@@ -52,8 +54,9 @@ export function useUnifiedResults(needId?: string) {
if (rt === 'FUTURE_AVAILABILITY') {
const signal = allSignals.find(s => s.id === refId || s.propertyId === refId)
if (!signal || signal.signalDirection === 'DEMAND') return []
+ const backingUnit = property.units?.find(u => u.id === signal.unitId)
return [{ matchId: match.id, needId: match.needId, matchScore: match.matchScore,
- resultType: 'FUTURE_AVAILABILITY', signal, match }]
+ resultType: 'FUTURE_AVAILABILITY', signal, match, property, unit: backingUnit }]
}
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts
index abe077b..3cd527e 100644
--- a/src/mock-data/properties.ts
+++ b/src/mock-data/properties.ts
@@ -46,9 +46,9 @@ export const mockProperties: Property[] = [
propertyNumber: 'ZH-2024-001',
units: [
- { id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
- { id: 'unit-001-2', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
- { id: 'unit-001-3', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480, isFlexible: true, minLettableSqm: 120 },
+ { 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-2', propertyId: 'prop-001', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, 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-3', propertyId: 'prop-001', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480, isFlexible: true, minLettableSqm: 120 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -152,9 +152,9 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-2021-007',
units: [
- { id: 'unit-007-1', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
- { id: 'unit-007-2', floorLevel: 1, unitLabel: '1.OG', areaSqm: 260, available: false, rentPricePerSqm: 432, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
- { id: 'unit-007-3', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445, isFlexible: true, minLettableSqm: 150 },
+ { 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-3', propertyId: 'prop-007', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445, isFlexible: true, minLettableSqm: 150 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -1253,6 +1253,10 @@ 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',
+ 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 } },
+ ],
importedFrom: 'SAP RE-FX',
importedAt: '2025-03-01T08:00:00Z',
lastUpdatedAt: '2026-04-15T10:00:00Z',
diff --git a/src/pages/demand/PropertyDetail.tsx b/src/pages/demand/PropertyDetail.tsx
new file mode 100644
index 0000000..ece98f4
--- /dev/null
+++ b/src/pages/demand/PropertyDetail.tsx
@@ -0,0 +1,288 @@
+import { useState } from 'react'
+import { useNavigate, useParams, useSearchParams } from 'react-router'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Divider,
+ Paper,
+ TextField,
+ Typography,
+} from '@mui/material'
+import {
+ ArrowLeft,
+ Building2,
+ Calendar,
+ CheckCircle2,
+ Mail,
+ MapPin,
+ ShieldCheck,
+ Layers,
+} from 'lucide-react'
+import { usePropertyById } from '../../hooks/useProperties'
+import type { PropertyUnit } from '../../domain/property'
+
+const FLOOR_LABEL = (level: number) =>
+ level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
+
+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' })
+ : '–'}
+
+
+
+ )
+}
+
+export default function PropertyDetail() {
+ const { propertyId } = useParams<{ propertyId: string }>()
+ const [searchParams] = useSearchParams()
+ const highlightUnitId = searchParams.get('unit')
+ const navigate = useNavigate()
+
+ const { data: property, isLoading } = usePropertyById(propertyId ?? '')
+
+ const [inquiryName, setInquiryName] = useState('')
+ const [inquiryText, setInquiryText] = useState('')
+ const [sent, setSent] = useState(false)
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!property) {
+ return (
+
+ Objekt nicht gefunden.
+
+ )
+ }
+
+ 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)
+
+ function handleSendInquiry() {
+ if (!inquiryName.trim() || !inquiryText.trim()) return
+ setSent(true)
+ }
+
+ return (
+
+ {/* Back */}
+ }
+ onClick={() => navigate(-1)}
+ size="small"
+ sx={{ mb: 2, textTransform: 'none', color: 'text.secondary' }}
+ >
+ Zurück zu den Ergebnissen
+
+
+ {/* Header */}
+
+ {property.images?.[0] && (
+
+ )}
+
+
+
+
+
+
+ {property.assetType}
+
+
+ {property.title}
+
+
+
+ {property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
+
+
+
+
+
+ CHF {monthlyRentDisplay}/m²/Mt.
+
+ {property.areaSqm.toLocaleString('de-CH')} m² total
+
+
+
+ {preMarketUnits.length > 0 && (
+
+
+
+ {preMarketUnits.length} Einheit{preMarketUnits.length !== 1 ? 'en' : ''} für Pre-Market freigegeben — noch vor offizieller Insertion
+
+
+ )}
+
+
+
+ {/* Units */}
+ {(property.units ?? []).length > 0 && (
+
+
+
+ Einheiten
+
+
+ {/* Column headers */}
+
+ {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
+ {h}
+ ))}
+
+
+ {preMarketUnits.map(u => (
+
+ ))}
+ {otherUnits.map(u => (
+
+ ))}
+
+ )}
+
+ {/* Inquiry */}
+
+
+
+ Verwaltung kontaktieren
+
+
+ {sent ? (
+ }
+ severity="success"
+ sx={{ borderRadius: 1 }}
+ >
+ Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
+
+ ) : (
+
+
+
+ Ihr Name
+ setInquiryName(e.target.value)}
+ />
+
+
+ Bezug
+ u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
+ : property.title
+ }
+ InputProps={{ readOnly: true }}
+ sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
+ />
+
+
+
+ Ihre Nachricht
+ setInquiryText(e.target.value)}
+ />
+
+
+
+
+ Antwortzeit: typisch 1–2 Werktage
+
+ }
+ sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
+ >
+ Anfrage senden
+
+
+
+ )}
+
+
+
+
+ Diese Fläche ist noch nicht offiziell auf dem Markt. Die Verwaltung hat sie explizit für qualifizierte Suchanfragen freigegeben. Ihre Anfrage wird vertraulich behandelt.
+
+
+ )
+}
diff --git a/src/provider/IUnitProvider.ts b/src/provider/IUnitProvider.ts
new file mode 100644
index 0000000..c8f4b4b
--- /dev/null
+++ b/src/provider/IUnitProvider.ts
@@ -0,0 +1,7 @@
+import type { PropertyUnit } from '../domain/property'
+
+export interface IUnitProvider {
+ getByPropertyId(propertyId: string): Promise
+ getById(unitId: string): Promise
+ update(unitId: string, data: Partial): Promise
+}
diff --git a/src/provider/MockupUnitProvider.ts b/src/provider/MockupUnitProvider.ts
new file mode 100644
index 0000000..4e29792
--- /dev/null
+++ b/src/provider/MockupUnitProvider.ts
@@ -0,0 +1,28 @@
+import type { IUnitProvider } from './IUnitProvider'
+import type { PropertyUnit } from '../domain/property'
+import { propertyStore } from './MockupPropertyProvider'
+
+function getAllUnits(): PropertyUnit[] {
+ return propertyStore.flatMap(p =>
+ (p.units ?? []).map(u => ({ ...u, propertyId: u.propertyId ?? p.id })),
+ )
+}
+
+export const MockupUnitProvider: IUnitProvider = {
+ async getByPropertyId(propertyId: string) {
+ const prop = propertyStore.find(p => p.id === propertyId)
+ return (prop?.units ?? []).map(u => ({ ...u, propertyId }))
+ },
+ async getById(unitId: string) {
+ return getAllUnits().find(u => u.id === unitId) ?? null
+ },
+ async update(unitId: string, data: Partial) {
+ for (const prop of propertyStore) {
+ const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
+ if (idx === -1) continue
+ prop.units![idx] = { ...prop.units![idx], ...data }
+ return prop.units![idx]
+ }
+ throw new Error(`Unit ${unitId} not found`)
+ },
+}