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
@@ -63,45 +63,19 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
</Box>
) : null}
{/* Lease & Tenant */}
<SectionTitle title="Miet- & Mieterinformationen" />
{editing ? (
{/* Floor / unit structure — primary tenant/lease view */}
<UnitStructurePanel p={p} />
{/* Fallback: property-level contract doc only when no unit-level contracts exist */}
{(() => {
const hasUnitContracts = p.units?.some(u => u.leases?.some(l => l.contractDocumentUrl))
if (editing) {
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1 }}>
<TextField
label="Aktueller Mieter"
size="small"
value={draft.currentTenant ?? p.currentTenant ?? ''}
onChange={e => onDraftChange({ ...draft, currentTenant: e.target.value })}
/>
<TextField
label="Mietlaufzeit"
size="small"
value={draft.leaseTerm ?? p.leaseTerm ?? ''}
onChange={e => onDraftChange({ ...draft, leaseTerm: e.target.value })}
placeholder="z.B. 5 Jahre"
/>
<TextField
label="Mietbeginn"
size="small"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
value={draft.leaseStartDate ?? p.leaseStartDate ?? ''}
onChange={e => onDraftChange({ ...draft, leaseStartDate: e.target.value })}
/>
<TextField
label="Mietende"
size="small"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
value={draft.leaseEndDate ?? p.leaseEndDate ?? ''}
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
<FileText size={13} color="#64748b" />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mietvertrag
Gebäude-Mietvertrag (Fallback)
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
@@ -122,32 +96,18 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
/>
</Box>
</Box>
) : (
<>
<FieldGrid>
<Field label="Aktueller Mieter" value={p.currentTenant} />
<Field label="Mietlaufzeit" value={p.leaseTerm} />
<Field label="Mietbeginn" value={p.leaseStartDate ? new Date(p.leaseStartDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Mietende" value={p.leaseEndDate ? new Date(p.leaseEndDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Breakoutoption" value={p.breakoutOption} />
<Field label="Breakoutoption Zeitpunkt" value={p.breakoutOptionDate ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
{/* Mietvertrag */}
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
<FileText size={13} color={p.leaseContractUrl ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#152642' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mietvertrag
</Typography>
</Box>
{p.leaseContractUrl ? (
)
}
if (!p.leaseContractUrl || hasUnitContracts) return null
return (
<Box sx={{ mt: 0.5, mb: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: '#f8fafc' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<FileText size={13} color="#152642" />
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
{p.leaseContractName ?? 'Mietvertrag'}
</Typography>
</Box>
<Button
size="small"
variant="outlined"
@@ -160,17 +120,9 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
Öffnen
</Button>
</Box>
) : (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Noch kein Mietvertrag hinterlegt im Bearbeitungsmodus hinzufügen.
</Typography>
)}
</Box>
</>
)}
{/* Floor / unit structure */}
<UnitStructurePanel p={p} />
)
})()}
{/* Pre-Market Matching */}
<PreMarketPanel p={p} />
@@ -230,6 +182,8 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
)}
+161 -37
View File
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import type { Lease } from '../../domain/lease'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
@@ -24,6 +25,25 @@ function MatchPill({ m }: { m: UnitNeedMatch }) {
)
}
function LeaseField({ label, value }: { label: string; value?: string }) {
if (!value) return null
return (
<Box>
<Typography sx={{ fontSize: '0.59rem', color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.3 }}>
{label}
</Typography>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b' }}>
{value}
</Typography>
</Box>
)
}
function formatLeaseDate(iso?: string): string | undefined {
if (!iso) return undefined
return new Date(iso).toLocaleDateString('de-CH', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function UnitStructurePanel({ p }: { p: Property }) {
const navigate = useNavigate()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -40,6 +60,23 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const bundle = useUnitBundle(selectedFreeUnits)
const bundleMatches = useBundleMatches(selectedFreeUnits, p)
// Group units by floor level (consecutive same-floor units form a group)
const floorGroups = useMemo(() => {
const groups: { floorLevel: number; units: PropertyUnit[] }[] = []
for (const u of p.units ?? []) {
const last = groups[groups.length - 1]
if (last && last.floorLevel === u.floorLevel) {
last.units.push(u)
} else {
groups.push({ floorLevel: u.floorLevel, units: [u] })
}
}
return groups
}, [p.units])
// Flat list of all units for index tracking
const allUnits = p.units ?? []
const toggleUnit = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
@@ -50,34 +87,18 @@ export function UnitStructurePanel({ p }: { p: Property }) {
if (!p.units || p.units.length === 0) return null
return (
<>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
Stockwerkstruktur
</Typography>
{freeUnits.length >= 2 && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.7rem' }}>
Freie Einheiten auswählen zum Kombinieren
</Typography>
)}
</Box>
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden', mb: 1.5 }}>
{/* Header */}
<Box sx={{ display: 'grid', gridTemplateColumns: '28px 72px 88px 1fr auto 80px', bgcolor: DS_SURFACE.neutral.bg, px: 1.5, py: 0.75, borderBottom: `1px solid ${DS_BORDER.default}`, alignItems: 'center' }}>
{['', 'Stockwerk', 'Einheit', 'Mieter / Status', 'Matches', 'Fläche'].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, fontSize: '0.63rem', textTransform: 'uppercase' }}>{h}</Typography>
))}
</Box>
{p.units!.map((u: PropertyUnit, i: number) => {
const renderUnitRow = (u: PropertyUnit, isFirstInGroup: boolean, isLastUnit: boolean) => {
const matches = u.available ? (unitMatches[u.id] ?? []) : []
const topMatch = matches[0]
const isExpanded = expandedUnit === u.id
const isSelected = selectedIds.has(u.id)
const isLastRow = i === p.units!.length - 1 && !isExpanded
const unitIndex = allUnits.indexOf(u)
const isLastRow = unitIndex === allUnits.length - 1 && !isExpanded
const activeLease: Lease | undefined = u.leases?.find(l => l.status === 'ACTIVE')
const tenantName = activeLease?.tenant.companyName ?? u.currentTenant
const leaseEnd = activeLease?.endDate ?? u.leaseEndDate
const hasLeaseDetail = !u.available && !!activeLease
return (
<Box key={u.id}>
@@ -93,7 +114,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
transition: 'background 0.15s',
}}
>
{/* Checkbox — only for free units */}
{/* Checkbox — only for free units when multiple free units exist */}
<Box>
{u.available && freeUnits.length >= 2 && (
<Checkbox
@@ -105,13 +126,15 @@ export function UnitStructurePanel({ p }: { p: Property }) {
)}
</Box>
{/* Floor */}
{/* Floor — only on first unit of group */}
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a' }}>
{floorLabel(u)}
{isFirstInGroup ? floorLabel(u) : ''}
</Typography>
{/* Unit label */}
<Typography variant="caption" sx={{ color: '#374151' }}>{u.unitLabel ?? ''}</Typography>
<Typography variant="caption" sx={{ color: '#374151' }}>
{u.unitLabel ?? ''}
</Typography>
{/* Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
@@ -143,7 +166,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
</Box>
</Tooltip>
{/* Inline min-unit editor — show when actively editing OR when teilbar is on but min sqm not set */}
{/* Inline min-unit editor */}
{(editingFlexUnit === u.id || (u.isFlexible && !u.minLettableSqm)) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField
@@ -211,7 +234,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
isFlexible: u.isFlexible,
minLettableSqm: u.minLettableSqm,
availableFrom: u.listing?.availableFrom ?? p.availabilityDate,
availableFrom: p.availabilityDate,
},
},
})}
@@ -220,14 +243,24 @@ export function UnitStructurePanel({ p }: { p: Property }) {
</Button>
</>
) : (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }} noWrap>{u.currentTenant ?? 'Vermietet'}</Typography>
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#374151' }} noWrap>
{tenantName ?? 'Vermietet'}
</Typography>
{leaseEnd && (
<Typography sx={{ fontSize: '0.6rem', color: DS_TEXT.muted, display: 'block' }}>
bis {new Date(leaseEnd).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
</Typography>
)}
</Box>
)}
</Box>
{/* Top match pill + expand */}
{/* Matches column + expand */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{topMatch && <MatchPill m={topMatch} />}
{matches.length > 1 && (
{/* Expand for free units with multiple matches */}
{u.available && matches.length > 1 && (
<IconButton
size="small"
onClick={() => setExpandedUnit(isExpanded ? null : u.id)}
@@ -236,6 +269,22 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</IconButton>
)}
{/* Expand for occupied units with lease detail */}
{hasLeaseDetail && (
<IconButton
size="small"
onClick={() => setExpandedUnit(isExpanded ? null : u.id)}
sx={{
p: 0.5,
color: isExpanded ? '#2563eb' : '#64748b',
borderRadius: 1,
'&:hover': { bgcolor: '#f1f5f9', color: '#2563eb' },
transition: 'color 0.15s, background 0.15s',
}}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
)}
</Box>
{/* Area */}
@@ -251,9 +300,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
</Box>
</Box>
{/* Expanded: show all matches for this unit */}
<Collapse in={isExpanded}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: i < p.units!.length - 1 ? `1px solid ${DS_BORDER.default}` : 'none' }}>
{/* Expanded: matches for free units */}
<Collapse in={isExpanded && u.available}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, fontSize: '0.65rem', textTransform: 'uppercase', display: 'block', mb: 0.75 }}>
Passende Suchanfragen für diese Einheit
</Typography>
@@ -273,10 +322,85 @@ export function UnitStructurePanel({ p }: { p: Property }) {
</Box>
</Box>
</Collapse>
{/* Expanded: lease detail for occupied units */}
<Collapse in={isExpanded && !u.available && !!activeLease}>
{activeLease && (
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.25, mb: activeLease.contractDocumentUrl ? 1.25 : 0 }}>
<LeaseField label="Mietbeginn" value={formatLeaseDate(activeLease.startDate)} />
<LeaseField label="Mietende" value={formatLeaseDate(activeLease.endDate)} />
<LeaseField label="Laufzeit" value={activeLease.contractDurationYears ? `${activeLease.contractDurationYears} Jahre` : undefined} />
<LeaseField label="Breakout-Option" value={activeLease.breakoutOption
? `Ja${activeLease.breakoutOptionDate ? ` · ${formatLeaseDate(activeLease.breakoutOptionDate)}` : ''}`
: 'Nein'
} />
<LeaseField label="Importiert aus" value={activeLease.sourceSystem} />
<LeaseField label="Zuletzt aktualisiert" value={formatLeaseDate(activeLease.lastUpdatedAt)} />
</Box>
{activeLease.contractDocumentUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pt: 0.75, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<FileText size={11} color="#152642" />
<Typography variant="caption" sx={{ fontWeight: 500, color: '#475569', flex: 1 }} noWrap>
{activeLease.contractDocumentName ?? 'Mietvertrag'}
</Typography>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={10} />}
href={activeLease.contractDocumentUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.2, px: 0.875, borderColor: '#cbd5e1', color: '#152642', flexShrink: 0 }}
>
Öffnen
</Button>
</Box>
)}
</Box>
)}
</Collapse>
</Box>
)
}
return (
<>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
Einheiten & Mietverträge
</Typography>
{freeUnits.length >= 2 && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.7rem' }}>
Freie Einheiten auswählen zum Kombinieren
</Typography>
)}
</Box>
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden', mb: 1.5 }}>
{/* Header */}
<Box sx={{ display: 'grid', gridTemplateColumns: '28px 72px 88px 1fr auto 80px', bgcolor: DS_SURFACE.neutral.bg, px: 1.5, py: 0.75, borderBottom: `1px solid ${DS_BORDER.default}`, alignItems: 'center' }}>
{['', 'Stockwerk', 'Einheit', 'Mieter / Status', '', 'Fläche'].map((h, i) => (
<Typography key={i} variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, fontSize: '0.63rem', textTransform: 'uppercase' }}>{h}</Typography>
))}
</Box>
{/* Floor groups */}
{floorGroups.map((group, groupIdx) => (
<Box
key={group.floorLevel}
sx={{ borderTop: groupIdx > 0 ? `2px solid ${DS_BORDER.default}` : 'none' }}
>
{group.units.map((u, unitIdxInGroup) => {
const isFirstInGroup = unitIdxInGroup === 0
const globalIdx = allUnits.indexOf(u)
const isLastUnit = globalIdx === allUnits.length - 1
return renderUnitRow(u, isFirstInGroup, isLastUnit)
})}
</Box>
))}
</Box>
{/* Bundle panel */}
{bundle && (
+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[]
+17 -4
View File
@@ -11,8 +11,9 @@ import type {
MatchCardReason,
} from '../../components/match-card/MatchCardViewModel'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { AvailabilityStatus } from '../../domain/enums'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -69,9 +70,19 @@ export function buildMatchCardViewModel(
cardTitle = property?.title ?? signal?.companyName ?? signal?.locationHint ?? ''
}
const availabilityLabel =
property?.availabilityDate ??
(signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
// Prefer unit-level availability (from Lease), fall back to property-level date, then signal
const unitAvail = unit ? getUnitAvailability(unit) : null
const availabilityLabel = (() => {
if (unitAvail?.availableFrom) {
return `ab ${new Date(unitAvail.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}`
}
if (unitAvail?.status === AvailabilityStatus.AVAILABLE_NOW) return 'Sofort verfügbar'
if (property?.availabilityDate) {
return `ab ${new Date(property.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}`
}
if (signal?.timeHorizonMonths) return `~${signal.timeHorizonMonths} Monate`
return undefined
})()
const sourceLabel =
property?.sourceLabel ??
@@ -134,6 +145,8 @@ export function buildMatchCardViewModel(
signalConfirmedFacts: signal?.confirmedFacts,
signalUnconfirmedFacts: signal?.unconfirmedFacts,
signalAreaSqmEstimate: signal?.areaSqmEstimate,
areaSqm: unit?.areaSqm ?? property?.areaSqm ?? signal?.areaSqmEstimate,
rentPerSqm: property?.rentPricePerSqm,
propertyId: property?.id ?? signal?.propertyId,
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
preMarketUnit: unit,
+18 -18
View File
@@ -49,9 +49,9 @@ export const mockProperties: Property[] = [
description: 'Moderne Bürofläche im aufstrebenden Stadtquartier Zürich-West, direkt beim Trendviertel Freilager. Die hellen, offen gestalteten Flächen bieten optimale Bedingungen für kollaboratives Arbeiten. Grosszügige Fensterfronten sorgen für viel Tageslicht. Das Gebäude verfügt über einen repräsentativen Empfangsbereich, Sitzungsräume sowie eine Gemeinschaftsterrasse mit Blick auf die Stadt. ÖV-Anbindung in unmittelbarer Nähe (Tram 4/13, S-Bahn Hardbrücke, 4 Minuten zu Fuss).',
units: [
{ id: 'unit-001-1', propertyId: 'prop-001', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
{ id: 'unit-001-2', propertyId: 'prop-001', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2026-10-31', schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
{ id: 'unit-001-3', propertyId: 'prop-001', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480, isFlexible: true, minLettableSqm: 120 },
{ id: 'unit-001-1', propertyId: 'prop-001', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, leases: [{ id: 'lease-001-1', unitId: 'unit-001-1', tenant: { id: 'ten-media-001', companyName: 'MediaGroup Schweiz AG', industry: 'Media & Kommunikation' }, rentPerSqm: 456, startDate: '2020-09-01', endDate: '2026-10-31', contractDurationYears: 5, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', sourceId: 'MV-2020-001', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', contractDocumentUrl: '/docs/mietvertrag-media-2020.pdf', contractDocumentName: 'Mietvertrag 20202026' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
{ id: 'unit-001-2', propertyId: 'prop-001', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, available: false, rentPricePerSqm: 456, leases: [{ id: 'lease-001-2', unitId: 'unit-001-2', tenant: { id: 'ten-media-001', companyName: 'MediaGroup Schweiz AG', industry: 'Media & Kommunikation' }, rentPerSqm: 456, startDate: '2020-09-01', endDate: '2026-10-31', contractDurationYears: 5, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', sourceId: 'MV-2020-002', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', contractDocumentUrl: '/docs/mietvertrag-media-2020.pdf', contractDocumentName: 'Mietvertrag 20202026' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
{ id: 'unit-001-3', propertyId: 'prop-001', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480, leases: [], isFlexible: true, minLettableSqm: 120 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -106,8 +106,8 @@ export const mockProperties: Property[] = [
propertyNumber: 'BS-2022-002',
description: 'Grossflächige Logistikanlage in direkter Rheinnähe, Kleinhüningen Basel. Zwei separate Lagerhallen A und B mit je eigenem Tor und Rampenanlage. Sprinkleranlage und Hallentemperierung vorhanden. Sehr gute Erschliessung via A2/A3, 30 Lastwagenstellplätze auf dem Areal. Ausbaupotenzial von 800 m² auf dem Grundstück verfügbar.',
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' } },
{ id: 'unit-002-1', propertyId: 'prop-002', floorLevel: 0, unitLabel: 'Halle A', areaSqm: 1400, available: false, rentPricePerSqm: 168, leases: [{ id: 'lease-002-1', unitId: 'unit-002-1', tenant: { id: 'ten-spedition-002', companyName: 'Spedition Rhein GmbH', industry: 'Logistik & Transport' }, rentPerSqm: 168, startDate: '2022-07-01', endDate: '2026-09-30', contractDurationYears: 3, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', sourceId: 'MV-2022-001', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-002-2', unitId: 'unit-002-2', tenant: { id: 'ten-spedition-002', companyName: 'Spedition Rhein GmbH', industry: 'Logistik & Transport' }, rentPerSqm: 168, startDate: '2022-07-01', endDate: '2026-09-30', contractDurationYears: 3, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', sourceId: 'MV-2022-002', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-09-30' } },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -164,9 +164,9 @@ export const mockProperties: Property[] = [
propertyNumber: 'ZH-2021-007',
description: 'Helle Büroflächen im 2. Obergeschoss an der Thurgauerstrasse, Zürich-Oerlikon. Drei Einheiten — zwei belegt und für Pre-Market freigegeben, eine flexible Einheit ab 150 m² direkt verfügbar. Hervorragende ÖV-Anbindung via Tram 11 und S-Bahn Oerlikon. Break-out-Option auf September 2026, danach gesamte 720 m² frei.',
units: [
{ id: 'unit-007-1', propertyId: 'prop-007', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
{ id: 'unit-007-2', propertyId: 'prop-007', floorLevel: 1, unitLabel: '1.OG', areaSqm: 260, available: false, rentPricePerSqm: 432, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2026-11-30', schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
{ id: 'unit-007-3', propertyId: 'prop-007', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445, isFlexible: true, minLettableSqm: 150 },
{ id: 'unit-007-1', propertyId: 'prop-007', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, leases: [{ id: 'lease-007-1', unitId: 'unit-007-1', tenant: { id: 'ten-consulting-007', companyName: 'Consulting Partners AG', industry: 'Unternehmensberatung' }, rentPerSqm: 420, startDate: '2021-10-01', endDate: '2026-11-30', contractDurationYears: 4, breakoutOption: true, breakoutOptionDate: '2026-09-01', status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
{ id: 'unit-007-2', propertyId: 'prop-007', floorLevel: 1, unitLabel: '1.OG', areaSqm: 260, available: false, rentPricePerSqm: 432, leases: [{ id: 'lease-007-2', unitId: 'unit-007-2', tenant: { id: 'ten-consulting-007', companyName: 'Consulting Partners AG', industry: 'Unternehmensberatung' }, rentPerSqm: 432, startDate: '2021-10-01', endDate: '2026-11-30', contractDurationYears: 4, breakoutOption: true, breakoutOptionDate: '2026-09-01', status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
{ id: 'unit-007-3', propertyId: 'prop-007', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445, leases: [], isFlexible: true, minLettableSqm: 150 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -223,10 +223,10 @@ export const mockProperties: Property[] = [
propertyNumber: 'BS-2021-008',
description: 'Moderne Büroflächen im renommierten Dreispitz-Areal, Hochbergerstrasse Basel. Vier unabhängige Etagen für verschiedene Teams oder Mieter. Parkhaus im Gebäude vorhanden, hervorragende Anbindung an Tram 11. Derzeit vollvermietet an Pharma Research GmbH — alle Einheiten für Pre-Market freigegeben.',
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 } },
{ id: 'unit-008-1', propertyId: 'prop-008', floorLevel: 1, unitLabel: '1.OG', areaSqm: 220, available: false, rentPricePerSqm: 384, leases: [{ id: 'lease-008-1', unitId: 'unit-008-1', tenant: { id: 'ten-pharma-008', companyName: 'Pharma Research GmbH', industry: 'Pharma & Life Sciences' }, rentPerSqm: 384, startDate: '2021-09-01', endDate: '2027-08-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-008-2', unitId: 'unit-008-2', tenant: { id: 'ten-pharma-008', companyName: 'Pharma Research GmbH', industry: 'Pharma & Life Sciences' }, rentPerSqm: 384, startDate: '2021-09-01', endDate: '2027-08-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-008-3', unitId: 'unit-008-3', tenant: { id: 'ten-pharma-008', companyName: 'Pharma Research GmbH', industry: 'Pharma & Life Sciences' }, rentPerSqm: 384, startDate: '2021-09-01', endDate: '2027-08-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-008-4', unitId: 'unit-008-4', tenant: { id: 'ten-pharma-008', companyName: 'Pharma Research GmbH', industry: 'Pharma & Life Sciences' }, rentPerSqm: 408, startDate: '2021-09-01', endDate: '2027-08-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: false } },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -281,8 +281,8 @@ export const mockProperties: Property[] = [
propertyNumber: 'ZH-2022-009',
description: 'Modernes Logistikzentrum im Töss-Quartier Winterthur, mit direkter A1-Anbindung. Lager Nord und Süd können separat oder gemeinsam angemietet werden. Ebene Andienung mit 4 Toren, Hallenhöhe 8 m, Bodenbelastung 3 t/m². 25 Lastwagenstellplätze. Ausbaupotenzial von 600 m² vorhanden.',
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' } },
{ id: 'unit-009-1', propertyId: 'prop-009', floorLevel: 0, unitLabel: 'Lager Nord', areaSqm: 900, available: false, rentPricePerSqm: 156, leases: [{ id: 'lease-009-1', unitId: 'unit-009-1', tenant: { id: 'ten-sperrgut-009', companyName: 'Sperrgut Logistik AG', industry: 'Logistik & Transport' }, rentPerSqm: 156, startDate: '2020-10-01', endDate: '2026-10-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-009-2', unitId: 'unit-009-2', tenant: { id: 'ten-sperrgut-009', companyName: 'Sperrgut Logistik AG', industry: 'Logistik & Transport' }, rentPerSqm: 156, startDate: '2020-10-01', endDate: '2026-10-31', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-10-31' } },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -337,8 +337,8 @@ export const mockProperties: Property[] = [
propertyNumber: 'ZH-2024-010',
description: 'Exklusive Retailfläche am Löwenplatz, Zürich-Innenstadt. Direkter Zugang zu Bahnhof und Tramknotenpunkt, maximale Laufkundschaft rund um die Uhr. EG-Verkaufsfläche mit repräsentativer Schaufensterfront. Derzeit von Fashion Concept GmbH belegt — Pre-Market-Freigabe bereits aktiv.',
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 } },
{ id: 'unit-010-1', propertyId: 'prop-010', floorLevel: 0, unitLabel: 'EG Verkauf', areaSqm: 205, available: false, rentPricePerSqm: 1056, leases: [{ id: 'lease-010-1', unitId: 'unit-010-1', tenant: { id: 'ten-fashion-010', companyName: 'Fashion Concept GmbH', industry: 'Retail & Mode' }, rentPerSqm: 1056, startDate: '2020-09-01', endDate: '2026-09-30', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-010-2', unitId: 'unit-010-2', tenant: { id: 'ten-fashion-010', companyName: 'Fashion Concept GmbH', industry: 'Retail & Mode' }, rentPerSqm: 432, startDate: '2020-09-01', endDate: '2026-09-30', contractDurationYears: 6, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: false } },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -393,8 +393,8 @@ export const mockProperties: Property[] = [
propertyNumber: 'BE-2023-011',
description: 'Weiträumige Produktions- und Logistikhalle im Gewerbequartier Brünnen Bern. Kranbahn (5 t) in Halle West, Dreiphasenstrom 400V vorhanden. Gut angebunden an A12-Anschluss Bern-Bümpliz. Zwei Hallenabschnitte separat oder gemeinsam anmietbar. Ausbaupotenzial von 1200 m² auf dem Grundstück möglich.',
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' } },
{ id: 'unit-011-1', propertyId: 'prop-011', floorLevel: 0, unitLabel: 'Halle West', areaSqm: 1600, available: false, rentPricePerSqm: 144, leases: [{ id: 'lease-011-1', unitId: 'unit-011-1', tenant: { id: 'ten-metallbau-011', companyName: 'Metallbau Bern AG', industry: 'Produktion & Metallverarbeitung' }, rentPerSqm: 144, startDate: '2015-11-01', endDate: '2026-11-30', contractDurationYears: 11, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], 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, leases: [{ id: 'lease-011-2', unitId: 'unit-011-2', tenant: { id: 'ten-metallbau-011', companyName: 'Metallbau Bern AG', industry: 'Produktion & Metallverarbeitung' }, rentPerSqm: 144, startDate: '2015-11-01', endDate: '2026-11-30', contractDurationYears: 11, breakoutOption: false, status: 'ACTIVE', sourceSystem: 'SAP_REFX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z' }], schattenmarktRelease: { enabled: true, availableFrom: '2026-11-30' } },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',