From 34a4dcfb2993406a8ab4e0f082f4ea52349fef72 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Thu, 21 May 2026 11:19:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20unit-first=20architecture=20=E2=80=94?= =?UTF-8?q?=20floor=20+=20street=20as=20primary=20card=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every VERIFIED_PORTFOLIO property now has explicit units in mock data (added to prop-002, prop-008, prop-009, prop-010, prop-011, prop-014). PropertyUnit extracted to domain/unit.ts with formatFloorLabel, formatUnitTitle, formatMultiUnitFloors helpers; re-exported from property.ts so all existing imports are unaffected. Cards now show floor + street as the primary title: - Specific unit match: "1.OG Nord · Zollstrasse 12" - Multi-unit (whole building): "1.OG–3.OG · Zollstrasse 12" - EG retail: "EG Verkauf · Löwenplatz 3" Building name (e.g. "Bürofläche Zollstrasse 12") appears as subtitle. Both IntelligenceMatchCard (grid) and MatchCardCompact (list) updated. Co-Authored-By: Claude Sonnet 4.6 --- .../match-card/IntelligenceMatchCard.tsx | 41 ++++------ .../match-card/MatchCardCompact.tsx | 5 ++ .../match-card/MatchCardViewModel.ts | 3 + src/domain/property.ts | 46 +----------- src/domain/unit.ts | 74 +++++++++++++++++++ src/features/matching/matchCardAdapter.ts | 25 +++++-- src/mock-data/properties.ts | 27 +++++++ 7 files changed, 145 insertions(+), 76 deletions(-) create mode 100644 src/domain/unit.ts diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 6b181d1..51eb95e 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -392,39 +392,28 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro {/* ── Card body ── */} + {/* Unit-first title (floor + street) */} {vm.title} + {/* Building name as subtitle when a unit or floor range is in the title */} + {vm.propertySubtitle && ( + + {vm.propertySubtitle} + + )} {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')} - {/* Specific named unit */} - {vm.preMarketUnit?.unitLabel && ( - - - - {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² - - - )} - - {/* Multi-unit property: no specific unit matched — show aggregate summary */} - {!vm.preMarketUnit && (vm.preMarketAllUnits?.length ?? 0) > 1 && ( - - - - total {vm.preMarketAllUnits!.reduce((s, u) => s + u.areaSqm, 0).toLocaleString('de-CH')} m² - - + {/* Unit area + rent when a specific unit is matched */} + {vm.preMarketUnit && ( + + {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² + {vm.preMarketUnit.rentPricePerSqm + ? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.` + : ''} + )} {/* Regular explainability summary */} diff --git a/src/components/match-card/MatchCardCompact.tsx b/src/components/match-card/MatchCardCompact.tsx index 423750e..4cf84bd 100644 --- a/src/components/match-card/MatchCardCompact.tsx +++ b/src/components/match-card/MatchCardCompact.tsx @@ -52,6 +52,11 @@ export function MatchCardCompact({ vm }: Props) { {vm.title} + {vm.propertySubtitle && ( + + {vm.propertySubtitle} + + )} diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts index ead58ec..fb3a3fe 100644 --- a/src/components/match-card/MatchCardViewModel.ts +++ b/src/components/match-card/MatchCardViewModel.ts @@ -67,6 +67,9 @@ export interface MatchCardViewModel { signalIsControlled?: boolean signalAreaSqmEstimate?: number + // Unit-first display (primary) + propertySubtitle?: string // building name shown below the unit title + // Property / unit reference (for FUTURE_AVAILABILITY with a backing property) propertyId?: string unitId?: string diff --git a/src/domain/property.ts b/src/domain/property.ts index e37bf64..82e62bb 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -4,6 +4,9 @@ import type { } from './enums' import { AvailabilityStatus as AS } from './enums' +// Re-export unit types so all existing imports from 'property' continue to work +export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit' + // ── Location / Address ──────────────────────────────────────────────────────── export interface Location { @@ -83,49 +86,6 @@ export interface SoftFactors { infrastructureNotes?: string } -// ── Floor / Unit structure ──────────────────────────────────────────────────── - -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 - available: boolean - rentPricePerSqm?: number - currentTenant?: string - leaseTerm?: string - leaseEndDate?: string - // Flexible letting - 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 -export interface UnitBundle { - unitIds: string[] - combinedSqm: number - label: string // e.g. "1.OG + 2.OG" -} - -// Result of unit-level need matching -export interface UnitNeedMatch { - needId: string - tenantName: string - tenantCompany?: string - requiredSqmMin: number - requiredSqmMax: number - matchScore: number - matchType: 'exact' | 'partial' | 'bundle' // how the unit satisfies the need - suggestedSqm?: number // for partial: how much of the unit to offer -} - // ── Property ────────────────────────────────────────────────────────────────── export interface Property { diff --git a/src/domain/unit.ts b/src/domain/unit.ts new file mode 100644 index 0000000..b9ed72e --- /dev/null +++ b/src/domain/unit.ts @@ -0,0 +1,74 @@ +// ── Rentable Unit — primary entity for demand-side matching ────────────────── +// +// A Property is the building/address container. +// A PropertyUnit is what is actually rented: a specific floor, wing, or section. +// Floor level is business-critical: retail needs EG for walk-in traffic, +// offices can occupy upper floors, logistics needs ground-level loading access. + +export interface PropertyUnit { + id: string + propertyId?: string // FK to parent Property + floorLevel: number // 0=EG, 1=1.OG, -1=UG1 + unitLabel?: string // e.g. "Nord", "West", "Einheit A" + areaSqm: number + available: boolean + rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm + currentTenant?: string + leaseTerm?: string + leaseEndDate?: string + // Flexible letting (Teilfläche) + isFlexible?: boolean + minLettableSqm?: number + offeredSqm?: number // currently offered area (≤ areaSqm) + // Unit-level pre-market release + schattenmarktRelease?: { + enabled: boolean + availableFrom?: string // ISO date + } +} + +// A bundle groups multiple free units into a combined offer +export interface UnitBundle { + unitIds: string[] + combinedSqm: number + label: string // e.g. "1.OG + 2.OG" +} + +// Result of unit-level need matching (used in supply-side detail views) +export interface UnitNeedMatch { + needId: string + tenantName: string + tenantCompany?: string + requiredSqmMin: number + requiredSqmMax: number + matchScore: number + matchType: 'exact' | 'partial' | 'bundle' + suggestedSqm?: number +} + +// ── Display helpers ─────────────────────────────────────────────────────────── + +export function formatFloorLabel(level: number): string { + if (level === 0) return 'EG' + if (level < 0) return `UG${Math.abs(level)}` + return `${level}.OG` +} + +/** Returns the primary display title for a specific unit: "1.OG Nord · Zollstrasse 12" */ +export function formatUnitTitle( + unit: PropertyUnit, + address: { street: string; houseNumber: string }, +): string { + const floor = formatFloorLabel(unit.floorLevel) + const label = unit.unitLabel ? ` ${unit.unitLabel}` : '' + return `${floor}${label} · ${address.street} ${address.houseNumber}` +} + +/** Returns a compact floor-range string for multi-unit properties: "1.OG–3.OG" or "EG · 1.OG" */ +export function formatMultiUnitFloors(units: PropertyUnit[]): string { + const floors = [...new Set(units.map(u => u.floorLevel))].sort((a, b) => a - b) + if (floors.length === 0) return '' + if (floors.length === 1) return formatFloorLabel(floors[0]) + if (floors.length === 2) return `${formatFloorLabel(floors[0])} · ${formatFloorLabel(floors[1])}` + return `${formatFloorLabel(floors[0])}–${formatFloorLabel(floors[floors.length - 1])}` +} diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index 14e6b94..8b1b405 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -11,6 +11,7 @@ import type { MatchCardReason, } from '../../components/match-card/MatchCardViewModel' import { getCityIntelligence } from '../../lib/locationIntelligence' +import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit' const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) @@ -50,8 +51,22 @@ export function buildMatchCardViewModel( ? `${city}${district ? `, ${district}` : ''}` : signal?.locationHint ?? '–' - // For PRE-MARKET: title comes from unit if available, then signal, then property + // Unit-first title: specific unit → floor range of all units → signal → property + let cardTitle: string + let propertySubtitle: string | undefined + if (unit && property?.address) { + cardTitle = formatUnitTitle(unit, property.address) + propertySubtitle = property.title + } else if (signal?.title) { + cardTitle = signal.title + } else if (property?.units && property.units.length > 0 && property.address) { + const floorRange = formatMultiUnitFloors(property.units) + cardTitle = `${floorRange} · ${property.address.street} ${property.address.houseNumber}` + propertySubtitle = property.title + } else { + cardTitle = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–' + } const availabilityLabel = property?.availabilityDate ?? @@ -74,12 +89,8 @@ export function buildMatchCardViewModel( return { id: result.matchId, - title: - signal?.title ?? - property?.title ?? - signal?.companyName ?? - signal?.locationHint ?? - '–', + title: cardTitle, + propertySubtitle, resultType, assetType: property?.assetType, matchScore, diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index 3cd527e..b78b84e 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -99,6 +99,10 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + units: [ + { id: 'unit-002-1', propertyId: 'prop-002', floorLevel: 0, unitLabel: 'Halle A', areaSqm: 1400, available: false, rentPricePerSqm: 168, currentTenant: 'Spedition Rhein GmbH', leaseTerm: '3 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } }, + { id: 'unit-002-2', propertyId: 'prop-002', floorLevel: 0, unitLabel: 'Halle B', areaSqm: 1000, available: false, rentPricePerSqm: 168, currentTenant: 'Spedition Rhein GmbH', leaseTerm: '3 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } }, + ], importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -207,6 +211,12 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1568992687947-868a62a9f521?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + units: [ + { id: 'unit-008-1', propertyId: 'prop-008', floorLevel: 1, unitLabel: '1.OG', areaSqm: 220, available: false, rentPricePerSqm: 384, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: true, availableFrom: '2027-08-31' } }, + { id: 'unit-008-2', propertyId: 'prop-008', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 250, available: false, rentPricePerSqm: 384, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: true, availableFrom: '2027-08-31' } }, + { id: 'unit-008-3', propertyId: 'prop-008', floorLevel: 3, unitLabel: '3.OG Ost', areaSqm: 230, available: false, rentPricePerSqm: 384, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: true, availableFrom: '2027-08-31' } }, + { id: 'unit-008-4', propertyId: 'prop-008', floorLevel: 4, unitLabel: '4.OG', areaSqm: 200, available: false, rentPricePerSqm: 408, currentTenant: 'Pharma Research GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2027-08-31', schattenmarktRelease: { enabled: false } }, + ], importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -256,6 +266,10 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + units: [ + { id: 'unit-009-1', propertyId: 'prop-009', floorLevel: 0, unitLabel: 'Lager Nord', areaSqm: 900, available: false, rentPricePerSqm: 156, currentTenant: 'Sperrgut Logistik AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } }, + { id: 'unit-009-2', propertyId: 'prop-009', floorLevel: 0, unitLabel: 'Lager Süd', areaSqm: 900, available: false, rentPricePerSqm: 156, currentTenant: 'Sperrgut Logistik AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } }, + ], importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -305,6 +319,10 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + units: [ + { id: 'unit-010-1', propertyId: 'prop-010', floorLevel: 0, unitLabel: 'EG Verkauf', areaSqm: 205, available: false, rentPricePerSqm: 1056, currentTenant: 'Fashion Concept GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } }, + { id: 'unit-010-2', propertyId: 'prop-010', floorLevel: -1, unitLabel: 'UG Lager', areaSqm: 80, available: false, rentPricePerSqm: 432, currentTenant: 'Fashion Concept GmbH', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: false } }, + ], importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -354,6 +372,10 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + units: [ + { id: 'unit-011-1', propertyId: 'prop-011', floorLevel: 0, unitLabel: 'Halle West', areaSqm: 1600, available: false, rentPricePerSqm: 144, currentTenant: 'Metallbau Bern AG', leaseTerm: '11 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } }, + { id: 'unit-011-2', propertyId: 'prop-011', floorLevel: 0, unitLabel: 'Halle Ost', areaSqm: 1200, available: false, rentPricePerSqm: 144, currentTenant: 'Metallbau Bern AG', leaseTerm: '11 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } }, + ], importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -520,6 +542,11 @@ export const mockProperties: Property[] = [ importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', + units: [ + { id: 'unit-014-1', propertyId: 'prop-014', floorLevel: 0, unitLabel: 'Lager A', areaSqm: 1600, available: false, rentPricePerSqm: 180, currentTenant: 'Handels- und Lagerbetrieb AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } }, + { id: 'unit-014-2', propertyId: 'prop-014', floorLevel: 0, unitLabel: 'Lager B', areaSqm: 1000, available: false, rentPricePerSqm: 180, currentTenant: 'Handels- und Lagerbetrieb AG', leaseTerm: '6 Jahre', leaseEndDate: '2026-09-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } }, + { id: 'unit-014-3', propertyId: 'prop-014', floorLevel: 0, unitLabel: 'Büro EG', areaSqm: 500, available: true, rentPricePerSqm: 240, isFlexible: true, minLettableSqm: 200 }, + ], currentTenant: 'Handels- und Lagerbetrieb AG', leaseTerm: '6 Jahre', leaseStartDate: '2020-09-01',