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
+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])}`
}