Files
property-match/src/domain/unit.ts
T
Benjamin Sutter 5d26dd4a6c feat(supply): unit-centric fit-out & overview — per-unit fit-out, editable demand budget, multi-unit price/availability
- domain/unit: optional per-unit fitOut override (falls back to property.hardFacts.fitOut)
- matchSyncService: pre-market unit scored with its own fitOut; matchCardAdapter prefers unit fitOut
- PreMarketUnitGrid/PreMarketPanel: per-unit Ausbaustandard override in the release list (default "Wie Objekt")
- PropertyDetailOverview: edit/read "Wer baut aus?" for existing listings (SHELL/BASIC), central FIT_OUT_LABELS
- Phase 1 unit-centric overview: header shows area+count, price as range, "Verfügbar ab → pro Einheit"; unit table shows per-unit price + availability; "Objekt & Lage" labels marked as object-defaults for multi-unit
- NeedExtendedRequirements: "Eigenes Ausbaubudget" only shown when min fit-out set + clearer helper text
- UnitStructurePanel: spin-off prefill carries fitOutByLandlord

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:06:31 +02:00

105 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Rentable Unit — primary entity for demand-side matching ──────────────────
//
// A Property is the building/address container (Liegenschaft).
// A PropertyUnit is what is actually rented: a specific floor, wing, or section (Mietobjekt).
// Floor level is an attribute of the unit — not a separate entity (matches Garaio REM / SAP RE-FX).
// Tenant and contract data live on Lease, not directly on the unit.
import type { Lease } from './lease'
import type { AvailabilityStatus } from './enums'
import { AvailabilityStatus as AS } from './enums'
export interface PropertyUnit {
id: string
propertyId?: string // FK to parent Property (Liegenschaft)
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
// Unit-level fit-out override — falls back to property.hardFacts.fitOut when unset.
// Relevant for pre-market, where units of one property are matched individually.
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */
currentTenant?: string
/** @deprecated Use leases[].endDate */
leaseEndDate?: string
/** @deprecated Use leases[0].contractDurationYears */
leaseTerm?: string
floorPlanUrl?: 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
anonymous?: boolean // hide address/name in pre-market feed
}
}
// Derives availability from the unit's lease list.
// Use this instead of reading property.availabilityStatus directly.
export function getUnitAvailability(unit: PropertyUnit): {
status: AvailabilityStatus
availableFrom?: string
activeLease?: Lease
} {
const active = unit.leases?.find(l => l.status === 'ACTIVE')
if (!active) return { status: AS.AVAILABLE_NOW }
const now = new Date()
const monthsToEnd = (new Date(active.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30)
if (monthsToEnd <= 12) {
return { status: AS.AVAILABLE_SOON, availableFrom: active.endDate, activeLease: active }
}
return { status: AS.OCCUPIED, activeLease: active }
}
// 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])}`
}