refactor: unit-centric lease view — Einheiten & Mietverträge

Replace building-level Miet- & Mieterinformationen section with
unit-level lease display. Each unit row now shows tenant name and
lease end date directly; expand reveals full contract details
(Mietbeginn, Mietende, Laufzeit, Breakout, source system, doc link).

Floor grouping groups consecutive same-floor units under one label.
Domain model extended with Lease and Tenant types; deprecated
property-level tenant fields preserved via @deprecated for compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-10 22:52:18 +02:00
parent d771029633
commit ee229f8f4f
9 changed files with 518 additions and 343 deletions
+2
View File
@@ -1,4 +1,6 @@
export * from './enums'
export * from './tenant'
export * from './lease'
export * from './property'
export * from './need'
export * from './match'
+20
View File
@@ -0,0 +1,20 @@
import type { Tenant } from './tenant'
export interface Lease {
id: string
unitId: string
tenant: Tenant
rentPerSqm: number // CHF/m²/Jahr
startDate: string // ISO date
endDate: string // ISO date
contractDurationYears?: number
breakoutOption?: boolean
breakoutOptionDate?: string
status: 'ACTIVE' | 'FUTURE' | 'EXPIRED'
sourceSystem?: 'SAP_REFX' | 'GARAIO_REM' | 'MANUAL' | string
sourceId?: string // ID in source ERP
importedAt?: string
lastUpdatedAt?: string
contractDocumentUrl?: string
contractDocumentName?: string
}
+32 -5
View File
@@ -4,6 +4,7 @@ import type {
} from './enums'
import { AvailabilityStatus as AS } from './enums'
import type { PropertyUnit } from './unit'
import type { Lease } from './lease'
// Re-export unit types so all existing imports from 'property' continue to work
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
@@ -137,18 +138,26 @@ export interface Property {
description?: string
images?: string[]
floorPlanUrl?: string
leaseContractUrl?: string // link to the signed lease document
leaseContractName?: string // display label, e.g. "Mietvertrag 20212026"
/** @deprecated Use unit.leases[].contractDocumentUrl instead */
leaseContractUrl?: string
/** @deprecated Use unit.leases[].contractDocumentName instead */
leaseContractName?: string
propertyNumber?: string
units?: PropertyUnit[]
mapImageUrl?: string
/** @deprecated Use unit.leases instead */
leaseTerm?: string
/** @deprecated Use unit.leases[].startDate instead */
leaseStartDate?: string
/** @deprecated Use unit.leases[].endDate instead */
leaseEndDate?: string
/** @deprecated Use unit.leases[].breakoutOption instead */
breakoutOption?: boolean
/** @deprecated Use unit.leases[].breakoutOptionDate instead */
breakoutOptionDate?: string
/** @deprecated Use unit.leases[].tenant.companyName instead */
currentTenant?: string
importedFrom?: string
importedAt?: string
@@ -169,9 +178,29 @@ export type UpdatePropertyInput = Partial<CreatePropertyInput>
* Returns the rentable units of a property.
* For properties without explicit units, synthesises one unit from property-level data
* so that all matching logic can operate uniformly at unit level.
* Property-level lease fields are synthesised into a proper Lease object for backward compat.
*/
export function getEffectiveUnits(p: Property): PropertyUnit[] {
if (p.units && p.units.length > 0) return p.units
const syntheticLease: Lease | undefined = p.currentTenant
? {
id: `${p.id}-lease`,
unitId: p.id,
tenant: { id: `${p.id}-tenant`, companyName: p.currentTenant },
rentPerSqm: p.rentPricePerSqm,
startDate: p.leaseStartDate ?? '',
endDate: p.leaseEndDate ?? '',
contractDurationYears: p.contractDurationMonths ? Math.round(p.contractDurationMonths / 12) : undefined,
breakoutOption: p.breakoutOption,
breakoutOptionDate: p.breakoutOptionDate,
status: p.leaseEndDate && new Date(p.leaseEndDate) > new Date() ? 'ACTIVE' : 'EXPIRED',
sourceSystem: p.importedFrom ?? 'MANUAL',
contractDocumentUrl: p.leaseContractUrl,
contractDocumentName: p.leaseContractName,
}
: undefined
return [{
id: p.id,
propertyId: p.id,
@@ -180,9 +209,7 @@ export function getEffectiveUnits(p: Property): PropertyUnit[] {
areaSqm: p.areaSqm,
available: p.availabilityStatus === AS.AVAILABLE_NOW || p.availabilityStatus === AS.AVAILABLE_SOON,
rentPricePerSqm: p.rentPricePerSqm,
currentTenant: p.currentTenant,
leaseTerm: p.leaseTerm,
leaseEndDate: p.leaseEndDate,
leases: syntheticLease ? [syntheticLease] : [],
schattenmarktRelease: p.schattenmarktRelease?.enabled
? { enabled: true, availableFrom: p.leaseEndDate }
: undefined,
+10
View File
@@ -0,0 +1,10 @@
export interface Tenant {
id: string
companyName: string
industry?: string
contactPerson?: {
name: string
email?: string
phone?: string
}
}
+32 -7
View File
@@ -1,22 +1,30 @@
// ── 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.
// 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
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
leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */
currentTenant?: string
leaseTerm?: string
/** @deprecated Use leases[].endDate */
leaseEndDate?: string
floorPlanUrl?: string // optional floor plan image
/** @deprecated Use leases[0].contractDurationYears */
leaseTerm?: string
floorPlanUrl?: string
// Flexible letting (Teilfläche)
isFlexible?: boolean
minLettableSqm?: number
@@ -28,6 +36,23 @@ export interface PropertyUnit {
}
}
// 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[]