From d902feb6c00df4d8b1b19ea98202265068195e39 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Wed, 20 May 2026 19:47:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Objekt=E2=86=92Einheit=20architecture?= =?UTF-8?q?=20=E2=80=94=20unit-level=20pre-market,=20property=20detail=20p?= =?UTF-8?q?age,=20click-through=20from=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 2 + .../match-card/IntelligenceMatchCard.tsx | 45 ++- .../match-card/MatchCardViewModel.ts | 7 + src/components/supply/PropertyDetailView.tsx | 89 ++++++ src/domain/futureSignal.ts | 3 + src/domain/property.ts | 6 + src/domain/unifiedResult.ts | 4 +- src/features/matching/matchCardAdapter.ts | 12 +- src/hooks/useSchattenmarktSignals.ts | 82 ++++- src/hooks/useUnifiedResults.ts | 7 +- src/mock-data/properties.ts | 16 +- src/pages/demand/PropertyDetail.tsx | 288 ++++++++++++++++++ src/provider/IUnitProvider.ts | 7 + src/provider/MockupUnitProvider.ts | 28 ++ 14 files changed, 578 insertions(+), 18 deletions(-) create mode 100644 src/pages/demand/PropertyDetail.tsx create mode 100644 src/provider/IUnitProvider.ts create mode 100644 src/provider/MockupUnitProvider.ts 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 */} + + + {/* 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 + + + + + )} + + + + + 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`) + }, +}