diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx
index 79b9d59..f5fd328 100644
--- a/src/components/supply/PropertyDetailView.tsx
+++ b/src/components/supply/PropertyDetailView.tsx
@@ -1,26 +1,30 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import {
Alert,
Box,
Button,
+ Checkbox,
Chip,
CircularProgress,
+ Collapse,
Divider,
IconButton,
LinearProgress,
Tab,
Tabs,
TextField,
+ Tooltip,
Typography,
} from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
-import { Edit2, Save, X } from 'lucide-react'
+import { ChevronDown, ChevronUp, Edit2, Layers, Save, Users, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { PropertyMap } from '../shared'
import { NeedMatchCard } from './NeedMatchCard'
import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
-import type { Property, PropertyUnit, UpdatePropertyInput } from '../../domain/property'
+import type { Property, PropertyUnit, UnitNeedMatch, UpdatePropertyInput } from '../../domain/property'
+import { unitMatchService } from '../../services/unitMatchService'
import type { PropertyNeedMatch } from '../../domain/match'
import { usePropertyById } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
@@ -75,6 +79,235 @@ function SectionTitle({ title }: { title: string }) {
)
}
+// ── Unit structure panel ──────────────────────────────────────────────────────
+
+function floorLabel(u: PropertyUnit): string {
+ if (u.floorLevel === 0) return 'EG'
+ if (u.floorLevel < 0) return `UG${Math.abs(u.floorLevel)}`
+ return `${u.floorLevel}.OG`
+}
+
+function MatchPill({ m }: { m: UnitNeedMatch }) {
+ const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
+ const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
+ return (
+
+
+
+
+ {m.tenantCompany ?? m.tenantName}
+
+ {m.matchScore}%
+
+
+ )
+}
+
+function UnitStructurePanel({ p }: { p: Property }) {
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [expandedUnit, setExpandedUnit] = useState(null)
+
+ const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
+
+ const unitMatches = useMemo(() =>
+ Object.fromEntries(freeUnits.map(u => [u.id, unitMatchService.getMatchesForUnit(u, p)])),
+ [freeUnits, p],
+ )
+
+ const selectedFreeUnits = freeUnits.filter(u => selectedIds.has(u.id))
+ const bundle = selectedFreeUnits.length >= 2 ? unitMatchService.buildBundle(selectedFreeUnits) : null
+ const bundleMatches = useMemo(() =>
+ selectedFreeUnits.length >= 2 ? unitMatchService.getMatchesForBundle(selectedFreeUnits, p) : [],
+ [selectedFreeUnits, p],
+ )
+
+ const toggleUnit = (id: string) => {
+ setSelectedIds(prev => {
+ const next = new Set(prev)
+ next.has(id) ? next.delete(id) : next.add(id)
+ return next
+ })
+ }
+
+ if (!p.units || p.units.length === 0) return null
+
+ return (
+ <>
+
+
+
+ Stockwerkstruktur
+
+ {freeUnits.length >= 2 && (
+
+ Freie Einheiten auswählen zum Kombinieren
+
+ )}
+
+
+
+ {/* Header */}
+
+ {['', 'Stockwerk', 'Einheit', 'Mieter / Status', 'Matches', 'Fläche'].map(h => (
+ {h}
+ ))}
+
+
+ {p.units!.map((u: PropertyUnit, i: number) => {
+ 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
+
+ return (
+
+
+ {/* Checkbox — only for free units */}
+
+ {u.available && freeUnits.length >= 2 && (
+ toggleUnit(u.id)}
+ sx={{ p: 0, color: '#94a3b8', '&.Mui-checked': { color: '#2563eb' } }}
+ />
+ )}
+
+
+ {/* Floor */}
+
+ {floorLabel(u)}
+
+
+ {/* Unit label */}
+ {u.unitLabel ?? '–'}
+
+ {/* Status */}
+
+ {u.available ? (
+ <>
+
+ {u.isFlexible && (
+
+ )}
+ >
+ ) : (
+ {u.currentTenant ?? 'Vermietet'}
+ )}
+
+
+ {/* Top match pill + expand */}
+
+ {topMatch && }
+ {matches.length > 1 && (
+ setExpandedUnit(isExpanded ? null : u.id)}
+ sx={{ p: 0.25, color: '#94a3b8' }}
+ >
+ {isExpanded ? : }
+
+ )}
+
+
+ {/* Area */}
+
+
+ {(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
+
+ {u.isFlexible && u.minLettableSqm && (
+
+ ab {u.minLettableSqm} m²
+
+ )}
+
+
+
+ {/* Expanded: show all matches for this unit */}
+
+
+
+ Passende Suchanfragen für diese Einheit
+
+
+ {matches.map(m => (
+
+
+
+ {m.requiredSqmMin}–{m.requiredSqmMax} m²
+ {m.matchType === 'partial' && m.suggestedSqm && ` · Teilfläche ~${m.suggestedSqm} m² anbieten`}
+
+
+ ))}
+ {matches.length === 0 && (
+ Keine passenden Suchanfragen
+ )}
+
+
+
+
+ )
+ })}
+
+
+ {/* Bundle panel */}
+ {bundle && (
+
+
+
+
+ Kombination: {bundle.label}
+
+
+
+
+ Diese Einheiten können gemeinsam oder separat vermietet werden.
+ {selectedFreeUnits.some(u => u.isFlexible) && ' Flexible Teilflächen möglich — Restfläche bleibt nach Vertragsabschluss verfügbar.'}
+
+ {bundleMatches.length > 0 ? (
+
+
+ Passende Suchanfragen für Kombination
+
+
+ {bundleMatches.map(m => )}
+
+
+ ) : (
+
+ Keine direkt passenden Suchanfragen für diese Kombination
+
+ )}
+
+ )}
+ >
+ )
+}
+
// ── Übersicht tab ─────────────────────────────────────────────────────────────
interface OverviewPanelProps {
@@ -183,36 +416,7 @@ function OverviewPanel({ p, editing, draft, onDraftChange }: OverviewPanelProps)
)}
{/* Floor / unit structure */}
- {p.units && p.units.length > 0 && (
- <>
-
-
-
- {/* header */}
-
- {['Stockwerk', 'Einheit', 'Mieter / Status', 'Fläche'].map(h => (
- {h}
- ))}
-
- {p.units.map((u: PropertyUnit, i: number) => (
-
-
- {u.floorLevel === 0 ? 'EG' : u.floorLevel < 0 ? `UG${Math.abs(u.floorLevel)}` : `${u.floorLevel}.OG`}
-
- {u.unitLabel ?? '–'}
-
- {u.available ? (
-
- ) : (
- {u.currentTenant ?? 'Vermietet'}
- )}
-
- {u.areaSqm.toLocaleString('de-CH')} m²
-
- ))}
-
- >
- )}
+
diff --git a/src/domain/property.ts b/src/domain/property.ts
index fd7f4b3..57ba142 100644
--- a/src/domain/property.ts
+++ b/src/domain/property.ts
@@ -94,6 +94,29 @@ export interface PropertyUnit {
currentTenant?: string
leaseTerm?: string
leaseEndDate?: string
+ // Flexible letting
+ isFlexible?: boolean // can be partially leased (Teilfläche)
+ minLettableSqm?: number // minimum area that can be leased standalone
+ offeredSqm?: number // currently offered area (≤ areaSqm); undefined = full unit
+}
+
+// A bundle groups multiple free units for a combined offer
+export interface UnitBundle {
+ unitIds: string[]
+ combinedSqm: number
+ label: string // e.g. "1.OG + 2.OG"
+}
+
+// Result of unit-level need matching
+export interface UnitNeedMatch {
+ needId: string
+ tenantName: string
+ tenantCompany?: string
+ requiredSqmMin: number
+ requiredSqmMax: number
+ matchScore: number
+ matchType: 'exact' | 'partial' | 'bundle' // how the unit satisfies the need
+ suggestedSqm?: number // for partial: how much of the unit to offer
}
// ── Property ──────────────────────────────────────────────────────────────────
diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts
index f6f35f9..bc424dd 100644
--- a/src/mock-data/properties.ts
+++ b/src/mock-data/properties.ts
@@ -47,7 +47,7 @@ export const mockProperties: Property[] = [
units: [
{ id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
{ id: 'unit-001-2', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
- { id: 'unit-001-3', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480 },
+ { id: 'unit-001-3', floorLevel: 3, unitLabel: 'West', areaSqm: 260, available: true, rentPricePerSqm: 480, isFlexible: true, minLettableSqm: 120 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -151,7 +151,7 @@ export const mockProperties: Property[] = [
units: [
{ id: 'unit-007-1', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
{ id: 'unit-007-2', floorLevel: 1, unitLabel: '1.OG', areaSqm: 260, available: false, rentPricePerSqm: 432, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
- { id: 'unit-007-3', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445 },
+ { id: 'unit-007-3', floorLevel: 2, unitLabel: '2.OG West', areaSqm: 280, available: true, rentPricePerSqm: 445, isFlexible: true, minLettableSqm: 150 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
@@ -456,8 +456,8 @@ export const mockProperties: Property[] = [
units: [
{ id: 'unit-013-1', floorLevel: 0, unitLabel: 'EG Laden', areaSqm: 320, available: false, rentPricePerSqm: 600, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
{ id: 'unit-013-2', floorLevel: 1, unitLabel: '1.OG Büro A', areaSqm: 480, available: false, rentPricePerSqm: 540, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
- { id: 'unit-013-3', floorLevel: 1, unitLabel: '1.OG Büro B', areaSqm: 280, available: true, rentPricePerSqm: 540 },
- { id: 'unit-013-4', floorLevel: 2, unitLabel: '2.OG', areaSqm: 220, available: true, rentPricePerSqm: 520 },
+ { id: 'unit-013-3', floorLevel: 1, unitLabel: '1.OG Büro B', areaSqm: 280, available: true, rentPricePerSqm: 540, isFlexible: true, minLettableSqm: 140 },
+ { id: 'unit-013-4', floorLevel: 2, unitLabel: '2.OG', areaSqm: 220, available: true, rentPricePerSqm: 520, isFlexible: true, minLettableSqm: 100 },
],
importedFrom: 'SAP RE-FX',
importedAt: '2025-01-15T08:00:00Z',
diff --git a/src/services/unitMatchService.ts b/src/services/unitMatchService.ts
new file mode 100644
index 0000000..2178e98
--- /dev/null
+++ b/src/services/unitMatchService.ts
@@ -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(' + '),
+ }
+ },
+}