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(' + '), + } + }, +}