feat: unit-first architecture — floor + street as primary card title
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 <noreply@anthropic.com>
This commit is contained in:
@@ -392,39 +392,28 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
|
||||
{/* ── Card body ── */}
|
||||
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Unit-first title (floor + street) */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
{/* Building name as subtitle when a unit or floor range is in the title */}
|
||||
{vm.propertySubtitle && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: '#475569', fontWeight: 500 }} noWrap>
|
||||
{vm.propertySubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
|
||||
{/* Specific named unit */}
|
||||
{vm.preMarketUnit?.unitLabel && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${floorLabel(vm.preMarketUnit.floorLevel)} · ${vm.preMarketUnit.unitLabel}`}
|
||||
sx={{ height: 18, fontSize: '0.62rem', bgcolor: '#f1f5f9', color: '#374151', border: '1px solid #e2e8f0' }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem' }}>
|
||||
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multi-unit property: no specific unit matched — show aggregate summary */}
|
||||
{!vm.preMarketUnit && (vm.preMarketAllUnits?.length ?? 0) > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${vm.preMarketAllUnits!.length} Einheiten`}
|
||||
sx={{ height: 18, fontSize: '0.62rem', bgcolor: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem' }}>
|
||||
total {vm.preMarketAllUnits!.reduce((s, u) => s + u.areaSqm, 0).toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Unit area + rent when a specific unit is matched */}
|
||||
{vm.preMarketUnit && (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
|
||||
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||
{vm.preMarketUnit.rentPricePerSqm
|
||||
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
|
||||
: ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Regular explainability summary */}
|
||||
|
||||
@@ -52,6 +52,11 @@ export function MatchCardCompact({ vm }: Props) {
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
{vm.propertySubtitle && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
||||
{vm.propertySubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
<MapPin size={13} color="#64748b" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-43
@@ -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 {
|
||||
|
||||
@@ -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])}`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user