feat: Objekt→Einheit architecture — unit-level pre-market, property detail page, click-through from cards

- Domain: PropertyUnit extended with propertyId + schattenmarktRelease per unit
- Domain: FutureAvailabilityResult carries resolved property + unit
- useSchattenmarktSignals: generates unit-level signals (schattenmarkt-{propId}-{unitId})
- useUnifiedResults: resolves backing property + unit on FUTURE_AVAILABILITY fast path
- IUnitProvider + MockupUnitProvider: first-class unit access and mutation
- matchCardAdapter: maps preMarketUnit, preMarketAllUnits, propertyId, unitId to ViewModel
- IntelligenceMatchCard: PRE-MARKET VERIFIED shows unit info strip + "Zur Einheit →" button
- PropertyDetailView: unit-level toggles + date pickers inside PreMarketPanel
- New page: /demand/property/:propertyId with unit table, status chips, inquiry form
- App.tsx: demand route /demand/property/:propertyId registered
- Mock data: prop-001/007/037 units updated with correct lease dates + unit-level schattenmarktRelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-20 19:47:59 +02:00
parent 66aabde1dc
commit d902feb6c0
14 changed files with 578 additions and 18 deletions
+2
View File
@@ -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() {
<Route path="/demand/compare" element={<Compare />} />
<Route path="/demand/shortlists" element={<Shortlists />} />
<Route path="/demand/pipeline" element={<Pipeline />} />
<Route path="/demand/property/:propertyId" element={<PropertyDetail />} />
</Route>
@@ -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<string, { label: string; color: string }> = {
@@ -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 }) {
</Box>
)}
{/* PRE-MARKET VERIFIED: specific unit info */}
{isControlled && vm.preMarketUnit && (
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
Freigegebene Einheit
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
</Typography>
)}
</Box>
</Box>
)}
{/* MARKET SIGNAL: market indicators */}
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
<Box sx={{ mb: 1.25 }}>
@@ -246,7 +276,7 @@ function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 'auto', pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Box sx={{ display: 'flex', gap: 0.75, mt: 'auto', pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)', flexWrap: 'wrap' }}>
{vm.actions.map(a => (
<Button
key={a.id}
@@ -265,6 +295,19 @@ function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{a.label}
</Button>
))}
{isControlled && vm.propertyId && (
<Button
size="small"
variant="contained"
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
}}
>
Zur Einheit
</Button>
)}
</Box>
{/* Disclaimer footnote */}
@@ -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
@@ -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<Record<string, { enabled: boolean; availableFrom: string }>>(() => {
const init: Record<string, { enabled: boolean; availableFrom: string }> = {}
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<Record<string, boolean>>({})
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 (
<>
<Divider sx={{ my: 2 }} />
@@ -459,6 +486,68 @@ function PreMarketPanel({ p }: { p: Property }) {
</Typography>
</Box>
{/* Unit-level release controls */}
{(p.units?.length ?? 0) > 0 && (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
Einheiten freigeben
</Typography>
{p.units!.map(u => {
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'center', py: 0.75,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b' }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: '#64748b' }}>
{u.areaSqm.toLocaleString('de-CH')} m²
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
</Typography>
</Box>
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value)
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: '#7c3aed' }} />}
<Switch
size="small"
checked={us.enabled}
onChange={(_, checked) => {
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' },
}}
/>
</Box>
</Box>
)
})}
</Box>
)}
{/* Demand Intelligence */}
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
+3
View File
@@ -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
}
+6
View File
@@ -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
+3 -1
View File
@@ -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 =
+10 -2
View File
@@ -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,
}
}
+74 -4
View File
@@ -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
// 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(buildSignal(p))
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}` : ''}`
+5 -2
View File
@@ -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') {
+10 -6
View File
@@ -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',
+288
View File
@@ -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 (
<Chip
size="small"
icon={<ShieldCheck size={11} />}
label="PRE-MARKET"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }}
/>
)
}
if (unit.available) {
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
}
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
}
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 80px 110px 120px auto',
gap: 1.5,
alignItems: 'center',
px: 2,
py: 1.5,
borderRadius: 1,
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
mb: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
</Typography>
{unit.currentTenant && (
<Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="body2" color="text.secondary">
{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : ''}
</Typography>
<Typography variant="body2" color="text.secondary">
{availableFrom
? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: ''}
</Typography>
<UnitStatusChip unit={unit} />
</Box>
)
}
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
<CircularProgress />
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 4 }}>
<Typography color="text.secondary">Objekt nicht gefunden.</Typography>
</Box>
)
}
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 (
<Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}>
{/* Back */}
<Button
startIcon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
size="small"
sx={{ mb: 2, textTransform: 'none', color: 'text.secondary' }}
>
Zurück zu den Ergebnissen
</Button>
{/* Header */}
<Paper sx={{ mb: 2, overflow: 'hidden' }}>
{property.images?.[0] && (
<Box
component="img"
src={property.images[0]}
alt={property.title}
sx={{ width: '100%', height: 220, objectFit: 'cover' }}
/>
)}
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Building2 size={15} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{property.assetType}
</Typography>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{property.title}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
CHF {monthlyRentDisplay}/m²/Mt.
</Typography>
<Typography variant="caption" color="text.secondary">{property.areaSqm.toLocaleString('de-CH')} m² total</Typography>
</Box>
</Box>
{preMarketUnits.length > 0 && (
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1 }}>
<ShieldCheck size={14} color="#7c3aed" />
<Typography sx={{ fontSize: '0.82rem', color: '#4c1d95', fontWeight: 600 }}>
{preMarketUnits.length} Einheit{preMarketUnits.length !== 1 ? 'en' : ''} für Pre-Market freigegeben noch vor offizieller Insertion
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Units */}
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
{/* Column headers */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
))}
</Box>
{preMarketUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId || preMarketUnits.length === 1} />
))}
{otherUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId} />
))}
</Paper>
)}
{/* Inquiry */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Verwaltung kontaktieren</Typography>
</Box>
{sent ? (
<Alert
icon={<CheckCircle2 size={18} />}
severity="success"
sx={{ borderRadius: 1 }}
>
Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihr Name</Typography>
<TextField
size="small"
fullWidth
placeholder="Max Muster"
value={inquiryName}
onChange={e => setInquiryName(e.target.value)}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Bezug</Typography>
<TextField
size="small"
fullWidth
value={
highlightUnitId
? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: property.title
}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihre Nachricht</Typography>
<TextField
size="small"
fullWidth
multiline
rows={4}
placeholder="Wir interessieren uns für die Fläche und möchten gerne einen Besichtigungstermin vereinbaren..."
value={inquiryText}
onChange={e => setInquiryText(e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={12} color="#64748b" />
<Typography variant="caption" color="text.secondary">Antwortzeit: typisch 12 Werktage</Typography>
</Box>
<Button
variant="contained"
size="small"
disabled={!inquiryName.trim() || !inquiryText.trim()}
onClick={handleSendInquiry}
startIcon={<Mail size={14} />}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
Anfrage senden
</Button>
</Box>
</Box>
)}
</Paper>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.5 }}>
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.
</Typography>
</Box>
)
}
+7
View File
@@ -0,0 +1,7 @@
import type { PropertyUnit } from '../domain/property'
export interface IUnitProvider {
getByPropertyId(propertyId: string): Promise<PropertyUnit[]>
getById(unitId: string): Promise<PropertyUnit | null>
update(unitId: string, data: Partial<PropertyUnit>): Promise<PropertyUnit>
}
+28
View File
@@ -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<PropertyUnit>) {
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`)
},
}