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:
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` : ''}`
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 1–2 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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`)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user