feat: unit-level matching — each floor lettable independently or as bundle

- PropertyUnit: add isFlexible, minLettableSqm, offeredSqm fields
- New UnitBundle + UnitNeedMatch domain types
- unitMatchService: match individual units or bundles against LatentNeeds
  (exact, partial/Teilfläche, bundle match types with area scoring)
- Mock units updated: prop-001/007/012/013 free units flagged as flexible
  with minimum lettable areas
- UnitStructurePanel replaces old Stockwerkstruktur table:
  - Checkboxes on free units (when ≥2 exist) for bundle selection
  - Inline match pills per free unit (top need + score)
  - Expand row to see all matching needs with area details
  - 'Teilfläche möglich' chip for flexible units with min sqm
  - Bundle panel appears when 2+ units selected: shows combined sqm,
    bundle matches, and note about remaining area after contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 20:24:17 +02:00
parent 7cda66f98a
commit f0e58f5f7a
4 changed files with 387 additions and 37 deletions
+123
View File
@@ -0,0 +1,123 @@
import type { Property, PropertyUnit, UnitBundle, UnitNeedMatch } from '../domain/property'
import { mockLatentNeeds } from '../mock-data/latentNeeds'
// How many m² of tolerance above need.max we still consider a match (e.g. 20%)
const OVERSIZE_TOLERANCE = 0.25
function areaScore(unitSqm: number, min: number, max: number): number {
const effectiveMax = max * (1 + OVERSIZE_TOLERANCE)
if (unitSqm < min || unitSqm > effectiveMax) return 0
// Perfect score when unit size is in the middle of the range
const mid = (min + max) / 2
const spread = max - min
const dist = Math.abs(unitSqm - mid)
return Math.max(0.5, 1 - dist / (spread + 1))
}
function assetTypeMatch(property: Property, needAssetType: string): number {
if (property.assetType === needAssetType) return 1
// Mixed-use partial credit
if (String(property.assetType).toUpperCase() === 'MIXED') return 0.7
return 0
}
/** Find needs that a single unit can satisfy (full or partial) */
export const unitMatchService = {
getMatchesForUnit(
unit: PropertyUnit,
property: Property,
): UnitNeedMatch[] {
const results: UnitNeedMatch[] = []
for (const need of mockLatentNeeds) {
const { min, max } = need.sizeRange
const atMatch = assetTypeMatch(property, String(need.assetType))
if (atMatch === 0) continue
const offeredSqm = unit.offeredSqm ?? unit.areaSqm
// Full unit fits the need
const fullScore = areaScore(offeredSqm, min, max)
if (fullScore > 0) {
results.push({
needId: need.id,
tenantName: need.tenantCompany ?? need.title,
tenantCompany: need.tenantCompany,
requiredSqmMin: min,
requiredSqmMax: max,
matchScore: Math.round(fullScore * atMatch * 95),
matchType: 'exact',
})
continue
}
// Partial: unit is larger than need.max but unit is flexible — offer a slice
if (unit.isFlexible && offeredSqm > max && (unit.minLettableSqm ?? 0) <= max) {
const partialSqm = Math.min(offeredSqm, max)
const partialScore = areaScore(partialSqm, min, max)
if (partialScore > 0) {
results.push({
needId: need.id,
tenantName: need.tenantCompany ?? need.title,
tenantCompany: need.tenantCompany,
requiredSqmMin: min,
requiredSqmMax: max,
matchScore: Math.round(partialScore * atMatch * 85),
matchType: 'partial',
suggestedSqm: partialSqm,
})
}
}
}
return results
.sort((a, b) => b.matchScore - a.matchScore)
.slice(0, 3)
},
/** Find needs that a bundle of units can satisfy together */
getMatchesForBundle(
units: PropertyUnit[],
property: Property,
): UnitNeedMatch[] {
const combinedSqm = units.reduce((sum, u) => sum + (u.offeredSqm ?? u.areaSqm), 0)
const results: UnitNeedMatch[] = []
for (const need of mockLatentNeeds) {
const { min, max } = need.sizeRange
const atMatch = assetTypeMatch(property, String(need.assetType))
if (atMatch === 0) continue
const score = areaScore(combinedSqm, min, max)
if (score > 0) {
results.push({
needId: need.id,
tenantName: need.tenantCompany ?? need.title,
tenantCompany: need.tenantCompany,
requiredSqmMin: min,
requiredSqmMax: max,
matchScore: Math.round(score * atMatch * 90),
matchType: 'bundle',
})
}
}
return results
.sort((a, b) => b.matchScore - a.matchScore)
.slice(0, 3)
},
/** Build a UnitBundle descriptor from selected units */
buildBundle(units: PropertyUnit[]): UnitBundle {
const floorLabel = (u: PropertyUnit) => {
if (u.floorLevel === 0) return 'EG'
if (u.floorLevel < 0) return `UG${Math.abs(u.floorLevel)}`
return `${u.floorLevel}.OG`
}
return {
unitIds: units.map(u => u.id),
combinedSqm: units.reduce((s, u) => s + (u.offeredSqm ?? u.areaSqm), 0),
label: units.map(u => `${floorLabel(u)}${u.unitLabel ? ' ' + u.unitLabel : ''}`).join(' + '),
}
},
}