5d26dd4a6c
- 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>
105 lines
4.2 KiB
TypeScript
105 lines
4.2 KiB
TypeScript
// ── 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.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])}`
|
||
}
|