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:
Benjamin Sutter
2026-05-21 11:19:13 +02:00
parent a39c6ed200
commit 34a4dcfb29
7 changed files with 145 additions and 76 deletions
@@ -392,39 +392,28 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{/* ── Card body ── */} {/* ── Card body ── */}
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}> <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> <Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{vm.title} {vm.title}
</Typography> </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 }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')} {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography> </Typography>
{/* Specific named unit */} {/* Unit area + rent when a specific unit is matched */}
{vm.preMarketUnit?.unitLabel && ( {vm.preMarketUnit && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}> <Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
<Chip {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
size="small" {vm.preMarketUnit.rentPricePerSqm
label={`${floorLabel(vm.preMarketUnit.floorLevel)} · ${vm.preMarketUnit.unitLabel}`} ? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
sx={{ height: 18, fontSize: '0.62rem', bgcolor: '#f1f5f9', color: '#374151', border: '1px solid #e2e8f0' }} : ''}
/> </Typography>
<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>
)} )}
{/* Regular explainability summary */} {/* Regular explainability summary */}
@@ -52,6 +52,11 @@ export function MatchCardCompact({ vm }: Props) {
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap> <Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{vm.title} {vm.title}
</Typography> </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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<MapPin size={13} color="#64748b" /> <MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -67,6 +67,9 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean signalIsControlled?: boolean
signalAreaSqmEstimate?: number 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) // Property / unit reference (for FUTURE_AVAILABILITY with a backing property)
propertyId?: string propertyId?: string
unitId?: string unitId?: string
+3 -43
View File
@@ -4,6 +4,9 @@ import type {
} from './enums' } from './enums'
import { AvailabilityStatus as AS } 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 ──────────────────────────────────────────────────────── // ── Location / Address ────────────────────────────────────────────────────────
export interface Location { export interface Location {
@@ -83,49 +86,6 @@ export interface SoftFactors {
infrastructureNotes?: string 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 ────────────────────────────────────────────────────────────────── // ── Property ──────────────────────────────────────────────────────────────────
export interface Property { export interface Property {
+74
View File
@@ -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.OG3.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])}`
}
+18 -7
View File
@@ -11,6 +11,7 @@ import type {
MatchCardReason, MatchCardReason,
} from '../../components/match-card/MatchCardViewModel' } from '../../components/match-card/MatchCardViewModel'
import { getCityIntelligence } from '../../lib/locationIntelligence' import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -50,8 +51,22 @@ export function buildMatchCardViewModel(
? `${city}${district ? `, ${district}` : ''}` ? `${city}${district ? `, ${district}` : ''}`
: signal?.locationHint ?? '' : 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 = const availabilityLabel =
property?.availabilityDate ?? property?.availabilityDate ??
@@ -74,12 +89,8 @@ export function buildMatchCardViewModel(
return { return {
id: result.matchId, id: result.matchId,
title: title: cardTitle,
signal?.title ?? propertySubtitle,
property?.title ??
signal?.companyName ??
signal?.locationHint ??
'',
resultType, resultType,
assetType: property?.assetType, assetType: property?.assetType,
matchScore, matchScore,
+27
View File
@@ -99,6 +99,10 @@ export const mockProperties: Property[] = [
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -207,6 +211,12 @@ export const mockProperties: Property[] = [
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1568992687947-868a62a9f521?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -256,6 +266,10 @@ export const mockProperties: Property[] = [
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -305,6 +319,10 @@ export const mockProperties: Property[] = [
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -354,6 +372,10 @@ export const mockProperties: Property[] = [
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], 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', 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', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -520,6 +542,11 @@ export const mockProperties: Property[] = [
importedFrom: 'SAP RE-FX', importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z', importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10: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', currentTenant: 'Handels- und Lagerbetrieb AG',
leaseTerm: '6 Jahre', leaseTerm: '6 Jahre',
leaseStartDate: '2020-09-01', leaseStartDate: '2020-09-01',