From 4d4ea6d2ff0852bc2a441167e6da8f33adb2c9dc Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 00:48:13 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20PropertyDetailView=20(998?= =?UTF-8?q?=E2=86=92192=20lines)=20and=20MatchDetail=20(464=E2=86=92236=20?= =?UTF-8?q?lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropertyDetailView.tsx extracted into 5 focused components: - PropertyDetailHelpers: Field, FieldGrid, SectionTitle, floorLabel - UnitStructurePanel: floor structure with unit matching - PreMarketPanel: schattenmarkt release controls - PropertyDetailOverview: overview tab content - MatchabilityTabPanel: need matches tab MatchDetail.tsx extracted into 2 focused components: - MatchDetailHero: image/map, header, key facts strip - MatchDetailPropertySections: Preis/Hauptangaben/Eigenschaften/etc. Co-Authored-By: Claude Sonnet 4.6 --- .../match-detail/MatchDetailHero.tsx | 128 +++ .../MatchDetailPropertySections.tsx | 166 ++++ .../supply/MatchabilityTabPanel.tsx | 56 ++ src/components/supply/PreMarketPanel.tsx | 287 ++++++ .../supply/PropertyDetailHelpers.tsx | 41 + .../supply/PropertyDetailOverview.tsx | 177 ++++ src/components/supply/PropertyDetailView.tsx | 820 +----------------- src/components/supply/UnitStructurePanel.tsx | 251 ++++++ src/pages/demand/MatchDetail.tsx | 262 +----- 9 files changed, 1130 insertions(+), 1058 deletions(-) create mode 100644 src/components/match-detail/MatchDetailHero.tsx create mode 100644 src/components/match-detail/MatchDetailPropertySections.tsx create mode 100644 src/components/supply/MatchabilityTabPanel.tsx create mode 100644 src/components/supply/PreMarketPanel.tsx create mode 100644 src/components/supply/PropertyDetailHelpers.tsx create mode 100644 src/components/supply/PropertyDetailOverview.tsx create mode 100644 src/components/supply/UnitStructurePanel.tsx diff --git a/src/components/match-detail/MatchDetailHero.tsx b/src/components/match-detail/MatchDetailHero.tsx new file mode 100644 index 0000000..68bab31 --- /dev/null +++ b/src/components/match-detail/MatchDetailHero.tsx @@ -0,0 +1,128 @@ +import { Box, Button, Chip, Divider, Typography } from '@mui/material' +import { Bookmark, Columns2 } from 'lucide-react' +import { PropertyMap } from '../shared' +import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay' +import { useMatchDetail } from '../../hooks/useMatches' +import type { Property } from '../../domain/property' +import type { FutureSignal } from '../../domain/futureSignal' + +type Match = NonNullable['data']> + +interface MatchDetailHeroProps { + match: Match + property: Property | undefined + signal: FutureSignal | null + isFuture: boolean + title: string + location: string + rt: { label: string; color: string } + keyFacts: Array<{ label: string; value: string }> + onCompare: () => void + onShortlist: () => void +} + +export function MatchDetailHero({ + match, + property, + signal: _signal, + isFuture, + title, + location, + rt, + keyFacts, + onCompare, + onShortlist, +}: MatchDetailHeroProps) { + return ( + <> + {/* Hero: image first, map fallback */} + {!isFuture && ( + property?.images?.[0] ? ( + + {property.title} + + ) : property?.location?.coordinates ? ( + + ) : null + )} + + {/* Property header — white section below hero */} + + + + {/* Left: title + address + chips */} + + + {title} + + + {location} + + + + {property?.assetType && ( + + )} + + + + {/* Right: score + actions */} + + + + + + + + + + + {/* Key facts strip */} + + + {keyFacts.map((fact, i) => ( + + + {fact.label} + + + {fact.value} + + + ))} + + + + ) +} diff --git a/src/components/match-detail/MatchDetailPropertySections.tsx b/src/components/match-detail/MatchDetailPropertySections.tsx new file mode 100644 index 0000000..447918d --- /dev/null +++ b/src/components/match-detail/MatchDetailPropertySections.tsx @@ -0,0 +1,166 @@ +import { Box, Button, Chip, Paper, Typography } from '@mui/material' +import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' +import { useMatchDetail } from '../../hooks/useMatches' +import type { Property } from '../../domain/property' +import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' + +type Match = NonNullable['data']> + +interface MatchDetailPropertySectionsProps { + property: Property + match: Match +} + +export function MatchDetailPropertySections({ property, match }: MatchDetailPropertySectionsProps) { + const units = property.units ?? [] + const matchedUnit = units.find(u => u.id === match.unitId) + const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null) + const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled) + const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled) + const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12) + const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12) + const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined) + const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType + + return ( + <> + {/* Preis */} + + + + Preis + + + + + {property.ancillaryCosts != null && ( + + )} + + + {/* Hauptangaben */} + + + + Hauptangaben + + + + + {minLettable != null && } + {property.contractDurationMonths != null && } + {(property.floorLevel != null || matchedUnit) && ( + + )} + {property.currentTenant && } + {property.leaseEndDate && } + {property.breakoutOption && } + {property.riskLevel && } + {property.expansionPotentialSqm != null && } + + + {/* Eigenschaften */} + {property.softFactors && ( + + + + Eigenschaften + + + {property.softFactors.publicTransportMinutes != null && ( + } label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} /> + )} + {property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && ( + + )} + {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( + + )} + {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( + + )} + {property.softFactors.passerbyFrequency && ( + + )} + {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( + + )} + + + )} + + {/* Wegzeit */} + {property.softFactors?.publicTransportMinutes != null && ( + + + + Wegzeit + + + + + + + {property.softFactors.publicTransportMinutes} Min. zu Fuss + Nächster ÖV-Anschluss — {property.location.city} + + + + Die Zeiten beziehen sich auf die Strecke zu Fuss. + + + )} + + {/* Einheiten */} + {units.length > 0 && ( + + + + Einheiten + + + {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => ( + {h} + ))} + + {preMarketUnits.map(u => )} + {otherUnits.map(u => )} + + )} + + {/* Beschreibung */} + {property.description && ( + + + + Beschreibung + + + {property.description} + + + )} + + {/* Quelle & Referenz */} + + + + Quelle & Referenz + + + {property.propertyNumber && } + {property.importedFrom && } + {property.dataQuality.lastVerifiedAt && ( + + )} + {property.sourceUrl && ( + + + + )} + + + ) +} diff --git a/src/components/supply/MatchabilityTabPanel.tsx b/src/components/supply/MatchabilityTabPanel.tsx new file mode 100644 index 0000000..5381ef8 --- /dev/null +++ b/src/components/supply/MatchabilityTabPanel.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { Alert, Box, CircularProgress, Typography } from '@mui/material' +import { useNavigate } from 'react-router' +import type { PropertyNeedMatch } from '../../domain/match' +import { matchService } from '../../services/matchService' +import { useOfferWizardStore } from '../../stores/offerWizardStore' +import { NeedMatchCard } from './NeedMatchCard' + +export function MatchabilityTabPanel({ propertyId }: { propertyId: string }) { + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) + const navigate = useNavigate() + const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties) + + useEffect(() => { + let cancelled = false + setLoading(true) + matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => { + if (!cancelled) { setMatches(result); setLoading(false) } + }) + return () => { cancelled = true } + }, [propertyId]) + + function handleNeedCardClick() { + setSelectedProperties([propertyId]) + navigate('/supply/anfragen', { state: { tab: 1 } }) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + + + Matchability + + + Bedarfsprofile mit ≥ 80% Match-Score — Karte klicken um Angebot zu erstellen + + + {matches.length === 0 ? ( + + Keine Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt gefunden. + + ) : ( + matches.map(m => ) + )} + + ) +} diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx new file mode 100644 index 0000000..1bd2824 --- /dev/null +++ b/src/components/supply/PreMarketPanel.tsx @@ -0,0 +1,287 @@ +import { useState } from 'react' +import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material' +import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import type { Property } from '../../domain/property' +import { MockupUnitProvider } from '../../provider/MockupUnitProvider' +import { propertyService } from '../../services/propertyService' +import { useToastStore } from '../../stores/toastStore' +import { floorLabel, SectionTitle } from './PropertyDetailHelpers' + +export const MOCK_TODAY = new Date('2026-05-20') + +export function PreMarketPanel({ p }: { p: Property }) { + const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false) + const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6) + const [saving, setSaving] = useState(false) + const [unitStates, setUnitStates] = useState>(() => { + const init: Record = {} + for (const u of p.units ?? []) { + init[u.id] = { + enabled: u.schattenmarktRelease?.enabled ?? false, + availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '', + } + } + return init + }) + const [unitSaving, setUnitSaving] = useState>({}) + const queryClient = useQueryClient() + const showToast = useToastStore(s => s.showToast) + + if (p.resultType !== 'VERIFIED_PORTFOLIO') return null + if (!p.leaseEndDate && !p.breakoutOptionDate) return null + + const candidates: Date[] = [] + if (p.leaseEndDate) { + const d = new Date(p.leaseEndDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) + } + if (p.breakoutOption && p.breakoutOptionDate) { + const d = new Date(p.breakoutOptionDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) + } + const triggerDate = candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null + const isActive = triggerDate ? MOCK_TODAY >= triggerDate : false + + const targetDate = (p.breakoutOption && p.breakoutOptionDate) + ? new Date(p.breakoutOptionDate) + : p.leaseEndDate ? new Date(p.leaseEndDate) : null + const monthsUntil = targetDate + ? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30))) + : null + + // Mock demand intelligence (derived deterministically from property characteristics) + const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) + + (['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1)) + const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38)) + + async function save(nextEnabled: boolean, nextLeadTime: number) { + setSaving(true) + try { + await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } }) + await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) + await queryClient.invalidateQueries({ queryKey: ['properties'] }) + showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success') + } catch { + showToast('Fehler beim Speichern.', 'error') + } finally { + setSaving(false) + } + } + + function handleToggle(_: React.ChangeEvent, checked: boolean) { + setEnabled(checked) + save(checked, leadTimeMonths) + } + + function handleLeadTime(months: number) { + setLeadTimeMonths(months) + if (enabled) save(enabled, months) + } + + async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string) { + setUnitSaving(prev => ({ ...prev, [unitId]: true })) + try { + await MockupUnitProvider.update(unitId, { + schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined }, + }) + await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) + await queryClient.invalidateQueries({ queryKey: ['properties'] }) + } catch { + showToast('Fehler beim Speichern der Einheit.', 'error') + } finally { + setUnitSaving(prev => ({ ...prev, [unitId]: false })) + } + } + + return ( + <> + + + + {/* Toggle row */} + + + + + + Pre-Market Matching aktivieren + + + Kontrollierte Freigabe für qualifizierte Suchanfragen — vor offizieller Insertion + + + + + {saving && } + + + + + {/* Active: lead time + status + demand intelligence */} + {enabled && ( + + {/* Lead time selector */} + + + Lead Time + + + {[3, 4, 5, 6, 8, 12].map(m => ( + handleLeadTime(m)} + sx={{ + height: 20, fontSize: '0.68rem', cursor: 'pointer', + bgcolor: leadTimeMonths === m ? '#7c3aed' : '#f1f5f9', + color: leadTimeMonths === m ? 'white' : '#374151', + '&:hover': { bgcolor: leadTimeMonths === m ? '#6d28d9' : '#e2e8f0' }, + }} + /> + ))} + + + + {/* Activation status */} + + {isActive + ? + : + } + + {isActive + ? `PRE-MARKET VERIFIED aktiv seit ${triggerDate?.toLocaleDateString('de-CH')} — Fläche im Matching-Feed sichtbar` + : triggerDate + ? `Freigabe startet ${triggerDate.toLocaleDateString('de-CH')} — noch ${monthsUntil} Monate bis Vertragsende` + : 'Kein Vertragsende hinterlegt' + } + + + + {/* Unit-level release controls */} + {(p.units?.length ?? 0) > 0 && ( + + + Einheiten freigeben + + {p.units!.map(u => { + const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' } + return ( + + + + {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} + + + {u.areaSqm.toLocaleString('de-CH')} m² + {u.currentTenant ? ` · ${u.currentTenant}` : ''} + + + { + const next = { ...us, availableFrom: e.target.value } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + if (us.enabled) saveUnit(u.id, true, e.target.value) + }} + /> + + {unitSaving[u.id] && } + { + const next = { ...us, enabled: checked } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + saveUnit(u.id, checked, us.availableFrom) + }} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, + }} + /> + + + ) + })} + + )} + + {/* Demand Intelligence */} + + + Matching Demand Intelligence + + + + + + {demandProfiles} aktive Suchprofile im System erkannt + + + + + + {highQualityLeads} hochwertige Suchanfragen mit passendem Flächenbedarf + + + + + + Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar + + + + + + )} + + {/* Inactive: explain value proposition */} + {!enabled && ( + + Wenn aktiviert, erscheint diese Fläche {leadTimeMonths} Monate vor Vertragsende als + verifiziertes PRE-MARKET VERIFIED Signal für qualifizierte Suchanfragen — + kein öffentliches Inserat, kontrolliertes Early Matching. + + )} + + + ) +} diff --git a/src/components/supply/PropertyDetailHelpers.tsx b/src/components/supply/PropertyDetailHelpers.tsx new file mode 100644 index 0000000..afa66f6 --- /dev/null +++ b/src/components/supply/PropertyDetailHelpers.tsx @@ -0,0 +1,41 @@ +import { Box, Typography } from '@mui/material' +import type { PropertyUnit } from '../../domain/property' + +export 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` +} + +export function Field({ label, value }: { label: string; value?: string | number | boolean | null }) { + return ( + + + {label} + + {value !== undefined && value !== null && value !== '' ? ( + + {typeof value === 'boolean' ? (value ? 'Ja' : 'Nein') : String(value)} + + ) : ( + + )} + + ) +} + +export function FieldGrid({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export function SectionTitle({ title }: { title: string }) { + return ( + + {title} + + ) +} diff --git a/src/components/supply/PropertyDetailOverview.tsx b/src/components/supply/PropertyDetailOverview.tsx new file mode 100644 index 0000000..fa24f79 --- /dev/null +++ b/src/components/supply/PropertyDetailOverview.tsx @@ -0,0 +1,177 @@ +import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material' +import type { Property, UpdatePropertyInput } from '../../domain/property' +import { PropertyMap } from '../shared' +import { getAssetTypeLabel, qualityColor } from './propertyHelpers' +import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers' +import { UnitStructurePanel } from './UnitStructurePanel' +import { PreMarketPanel } from './PreMarketPanel' + +interface PropertyDetailOverviewProps { + p: Property + editing: boolean + draft: UpdatePropertyInput + onDraftChange: (d: UpdatePropertyInput) => void +} + +export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) { + const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined + + return ( + + {/* Key metrics */} + + {[ + { label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` }, + { label: 'CHF/m²/Jahr', value: rentLabel }, + { label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined }, + ].map(({ label, value }) => ( + + {label} + + {value ?? k.A.} + + + ))} + + {p.propertyNumber && ( + + Objekt-Nr. {p.propertyNumber} + + )} + + {/* Description */} + {editing ? ( + + + onDraftChange({ ...draft, description: e.target.value })} + placeholder="Beschreibung des Objekts…" + /> + + ) : p.description ? ( + + + + {p.description} + + + ) : null} + + {/* Lease & Tenant */} + + {editing ? ( + + + onDraftChange({ ...draft, currentTenant: e.target.value })} + /> + onDraftChange({ ...draft, leaseTerm: e.target.value })} + placeholder="z.B. 5 Jahre" + /> + onDraftChange({ ...draft, leaseStartDate: e.target.value })} + /> + onDraftChange({ ...draft, leaseEndDate: e.target.value })} + /> + + + ) : ( + + + + + + + + + + + )} + + {/* Floor / unit structure */} + + + {/* Pre-Market Matching */} + + + + + {/* Object details */} + + + {p.propertyNumber && } + + + + + + + + + {/* Map */} + {p.location.coordinates && ( + + + + Standort + + + {p.address.street} {p.address.houseNumber}, {p.address.postalCode} {p.address.city} + + + + + )} + + + + {/* Data quality */} + + + Score + {Math.round(p.dataQuality.score * 100)}% + + + {p.dataQuality.warnings.length > 0 && ( + + {p.dataQuality.warnings.map((w, i) => ( + ⚠ {w} + ))} + + )} + + ) +} diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index df57862..2019ce6 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -1,36 +1,20 @@ -import { useEffect, useMemo, useState } from 'react' -import { useNavigate } from 'react-router' +import { useState } from 'react' import { Alert, Box, Button, - Checkbox, Chip, CircularProgress, - Collapse, - Divider, IconButton, - LinearProgress, - Switch, Tab, Tabs, - TextField, - Tooltip, Typography, } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' -import { ChevronDown, ChevronUp, Clock, Edit2, Layers, Save, ShieldCheck, Target, TrendingUp, Users, X, Zap } from 'lucide-react' -import { useOfferWizardStore } from '../../stores/offerWizardStore' -import { PropertyMap } from '../shared' -import { NeedMatchCard } from './NeedMatchCard' -import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab' -import type { Property, PropertyUnit, UnitNeedMatch, UpdatePropertyInput } from '../../domain/property' -import { MockupUnitProvider } from '../../provider/MockupUnitProvider' -import { unitMatchService } from '../../services/unitMatchService' -import type { PropertyNeedMatch } from '../../domain/match' +import { Edit2, Save, X } from 'lucide-react' +import type { UpdatePropertyInput } from '../../domain/property' import { usePropertyById } from '../../hooks/useProperties' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' -import { matchService } from '../../services/matchService' import { propertyService } from '../../services/propertyService' import { useToastStore } from '../../stores/toastStore' import { @@ -38,806 +22,16 @@ import { getAssetTypeLabel, getAvailabilityChipColor, getAvailabilityLabel, - qualityColor, } from './propertyHelpers' +import { PropertyDetailOverview } from './PropertyDetailOverview' +import { MatchabilityTabPanel } from './MatchabilityTabPanel' +import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab' interface PropertyDetailViewProps { propertyId: string onClose?: () => void } -// ── Helpers ──────────────────────────────────────────────────────────────────── - -function Field({ label, value }: { label: string; value?: string | number | boolean | null }) { - return ( - - - {label} - - {value !== undefined && value !== null && value !== '' ? ( - - {typeof value === 'boolean' ? (value ? 'Ja' : 'Nein') : String(value)} - - ) : ( - - )} - - ) -} - -function FieldGrid({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function SectionTitle({ title }: { title: string }) { - return ( - - {title} - - ) -} - -// ── 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 navigate = useNavigate() - 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 - - )} - - )} - - ) -} - -// ── Pre-Market Matching panel ───────────────────────────────────────────────── - -const MOCK_TODAY = new Date('2026-05-20') - -function PreMarketPanel({ p }: { p: Property }) { - const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false) - const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6) - const [saving, setSaving] = useState(false) - const [unitStates, setUnitStates] = useState>(() => { - const init: Record = {} - for (const u of p.units ?? []) { - init[u.id] = { - enabled: u.schattenmarktRelease?.enabled ?? false, - availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '', - } - } - return init - }) - const [unitSaving, setUnitSaving] = useState>({}) - const queryClient = useQueryClient() - const showToast = useToastStore(s => s.showToast) - - if (p.resultType !== 'VERIFIED_PORTFOLIO') return null - if (!p.leaseEndDate && !p.breakoutOptionDate) return null - - const candidates: Date[] = [] - if (p.leaseEndDate) { - const d = new Date(p.leaseEndDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) - } - if (p.breakoutOption && p.breakoutOptionDate) { - const d = new Date(p.breakoutOptionDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) - } - const triggerDate = candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null - const isActive = triggerDate ? MOCK_TODAY >= triggerDate : false - - const targetDate = (p.breakoutOption && p.breakoutOptionDate) - ? new Date(p.breakoutOptionDate) - : p.leaseEndDate ? new Date(p.leaseEndDate) : null - const monthsUntil = targetDate - ? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30))) - : null - - // Mock demand intelligence (derived deterministically from property characteristics) - const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) + - (['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1)) - const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38)) - - async function save(nextEnabled: boolean, nextLeadTime: number) { - setSaving(true) - try { - await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } }) - await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) - await queryClient.invalidateQueries({ queryKey: ['properties'] }) - showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success') - } catch { - showToast('Fehler beim Speichern.', 'error') - } finally { - setSaving(false) - } - } - - function handleToggle(_: React.ChangeEvent, checked: boolean) { - setEnabled(checked) - save(checked, leadTimeMonths) - } - - function handleLeadTime(months: number) { - setLeadTimeMonths(months) - if (enabled) save(enabled, months) - } - - async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string) { - setUnitSaving(prev => ({ ...prev, [unitId]: true })) - try { - await MockupUnitProvider.update(unitId, { - schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined }, - }) - await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) - await queryClient.invalidateQueries({ queryKey: ['properties'] }) - } catch { - showToast('Fehler beim Speichern der Einheit.', 'error') - } finally { - setUnitSaving(prev => ({ ...prev, [unitId]: false })) - } - } - - return ( - <> - - - - {/* Toggle row */} - - - - - - Pre-Market Matching aktivieren - - - Kontrollierte Freigabe für qualifizierte Suchanfragen — vor offizieller Insertion - - - - - {saving && } - - - - - {/* Active: lead time + status + demand intelligence */} - {enabled && ( - - {/* Lead time selector */} - - - Lead Time - - - {[3, 4, 5, 6, 8, 12].map(m => ( - handleLeadTime(m)} - sx={{ - height: 20, fontSize: '0.68rem', cursor: 'pointer', - bgcolor: leadTimeMonths === m ? '#7c3aed' : '#f1f5f9', - color: leadTimeMonths === m ? 'white' : '#374151', - '&:hover': { bgcolor: leadTimeMonths === m ? '#6d28d9' : '#e2e8f0' }, - }} - /> - ))} - - - - {/* Activation status */} - - {isActive - ? - : - } - - {isActive - ? `PRE-MARKET VERIFIED aktiv seit ${triggerDate?.toLocaleDateString('de-CH')} — Fläche im Matching-Feed sichtbar` - : triggerDate - ? `Freigabe startet ${triggerDate.toLocaleDateString('de-CH')} — noch ${monthsUntil} Monate bis Vertragsende` - : 'Kein Vertragsende hinterlegt' - } - - - - {/* Unit-level release controls */} - {(p.units?.length ?? 0) > 0 && ( - - - Einheiten freigeben - - {p.units!.map(u => { - const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' } - return ( - - - - {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} - - - {u.areaSqm.toLocaleString('de-CH')} m² - {u.currentTenant ? ` · ${u.currentTenant}` : ''} - - - { - const next = { ...us, availableFrom: e.target.value } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - if (us.enabled) saveUnit(u.id, true, e.target.value) - }} - /> - - {unitSaving[u.id] && } - { - const next = { ...us, enabled: checked } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - saveUnit(u.id, checked, us.availableFrom) - }} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, - }} - /> - - - ) - })} - - )} - - {/* Demand Intelligence */} - - - Matching Demand Intelligence - - - - - - {demandProfiles} aktive Suchprofile im System erkannt - - - - - - {highQualityLeads} hochwertige Suchanfragen mit passendem Flächenbedarf - - - - - - Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar - - - - - - )} - - {/* Inactive: explain value proposition */} - {!enabled && ( - - Wenn aktiviert, erscheint diese Fläche {leadTimeMonths} Monate vor Vertragsende als - verifiziertes PRE-MARKET VERIFIED Signal für qualifizierte Suchanfragen — - kein öffentliches Inserat, kontrolliertes Early Matching. - - )} - - - ) -} - -// ── Übersicht tab ───────────────────────────────────────────────────────────── - -interface OverviewPanelProps { - p: Property - editing: boolean - draft: UpdatePropertyInput - onDraftChange: (d: UpdatePropertyInput) => void -} - -function OverviewPanel({ p, editing, draft, onDraftChange }: OverviewPanelProps) { - const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined - - return ( - - {/* Key metrics */} - - {[ - { label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` }, - { label: 'CHF/m²/Jahr', value: rentLabel }, - { label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined }, - ].map(({ label, value }) => ( - - {label} - - {value ?? k.A.} - - - ))} - - {p.propertyNumber && ( - - Objekt-Nr. {p.propertyNumber} - - )} - - {/* Description */} - {editing ? ( - - - onDraftChange({ ...draft, description: e.target.value })} - placeholder="Beschreibung des Objekts…" - /> - - ) : p.description ? ( - - - - {p.description} - - - ) : null} - - {/* Lease & Tenant */} - - {editing ? ( - - - onDraftChange({ ...draft, currentTenant: e.target.value })} - /> - onDraftChange({ ...draft, leaseTerm: e.target.value })} - placeholder="z.B. 5 Jahre" - /> - onDraftChange({ ...draft, leaseStartDate: e.target.value })} - /> - onDraftChange({ ...draft, leaseEndDate: e.target.value })} - /> - - - ) : ( - - - - - - - - - - - )} - - {/* Floor / unit structure */} - - - {/* Pre-Market Matching */} - - - - - {/* Object details */} - - - {p.propertyNumber && } - - - - - - - - - {/* Map */} - {p.location.coordinates && ( - - - - Standort - - - {p.address.street} {p.address.houseNumber}, {p.address.postalCode} {p.address.city} - - - - - )} - - - - {/* Data quality */} - - - Score - {Math.round(p.dataQuality.score * 100)}% - - - {p.dataQuality.warnings.length > 0 && ( - - {p.dataQuality.warnings.map((w, i) => ( - ⚠ {w} - ))} - - )} - - ) -} - -// ── Matchability tab ────────────────────────────────────────────────────────── - -function MatchabilityTabPanel({ propertyId }: { propertyId: string }) { - const [matches, setMatches] = useState([]) - const [loading, setLoading] = useState(true) - const navigate = useNavigate() - const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties) - - useEffect(() => { - let cancelled = false - setLoading(true) - matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => { - if (!cancelled) { setMatches(result); setLoading(false) } - }) - return () => { cancelled = true } - }, [propertyId]) - - function handleNeedCardClick() { - setSelectedProperties([propertyId]) - navigate('/supply/anfragen', { state: { tab: 1 } }) - } - - if (loading) { - return ( - - - - ) - } - - return ( - - - - Matchability - - - Bedarfsprofile mit ≥ 80% Match-Score — Karte klicken um Angebot zu erstellen - - - {matches.length === 0 ? ( - - Keine Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt gefunden. - - ) : ( - matches.map(m => ) - )} - - ) -} - -// ── Main component ──────────────────────────────────────────────────────────── - const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) { @@ -983,7 +177,7 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr {/* Tab content */} {tab === 0 && ( - = 85 ? '#fef3c7' : '#e0e7ff' + const color = m.matchScore >= 85 ? '#92400e' : '#3730a3' + return ( + + + + + {m.tenantCompany ?? m.tenantName} + + {m.matchScore}% + + + ) +} + +export function UnitStructurePanel({ p }: { p: Property }) { + const navigate = useNavigate() + 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 + + )} + + )} + + ) +} diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 8dc80f4..9a651af 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -1,11 +1,10 @@ -import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material' -import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' +import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material' +import { ArrowLeft } from 'lucide-react' import { useNavigate, useParams } from 'react-router' import { useCompareStore } from '../../stores/compareStore' import { usePipelineStore } from '../../stores/pipelineStore' import { AddToPipelineDialog } from '../../components/shortlist' import { MatchReasonList } from '../../components/match-card/MatchReasonList' -import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay' import { PropertyMap } from '../../components/shared' import { getCityIntelligence } from '../../lib/locationIntelligence' import { @@ -21,8 +20,9 @@ import { } from '../../components/match-detail' import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' import { useMatchDetailData } from '../../hooks/useMatchDetailData' -import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from '../../components/match-detail/MatchDetailPropertyDetails' import { useMatchDetail } from '../../hooks/useMatches' +import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero' +import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections' // ── Match helpers ────────────────────────────────────────────────────────────── @@ -147,94 +147,18 @@ export default function MatchDetail() { )} - {/* Hero: image first, map fallback */} - {!isFuture && ( - property?.images?.[0] ? ( - - {property.title} - - ) : property?.location?.coordinates ? ( - - ) : null - )} - - {/* Property header — white section below hero */} - - - - {/* Left: title + address + chips */} - - - {title} - - - {location} - - - - {property?.assetType && ( - - )} - - - - {/* Right: score + actions */} - - - - - - - - - - - {/* Key facts strip */} - - - {keyFacts.map((fact, i) => ( - - - {fact.label} - - - {fact.value} - - - ))} - - + {/* Main content */} @@ -245,159 +169,7 @@ export default function MatchDetail() { {/* ── Property Details ── */} - {!isFuture && property && (() => { - const units = property.units ?? [] - const matchedUnit = units.find(u => u.id === match.unitId) - const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null) - const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled) - const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled) - const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12) - const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12) - const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined) - const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType - - return ( - <> - {/* Preis */} - - - - Preis - - - - - {property.ancillaryCosts != null && ( - - )} - - - {/* Hauptangaben */} - - - - Hauptangaben - - - - - {minLettable != null && } - {property.contractDurationMonths != null && } - {(property.floorLevel != null || matchedUnit) && ( - - )} - {property.currentTenant && } - {property.leaseEndDate && } - {property.breakoutOption && } - {property.riskLevel && } - {property.expansionPotentialSqm != null && } - - - {/* Eigenschaften */} - {property.softFactors && ( - - - - Eigenschaften - - - {property.softFactors.publicTransportMinutes != null && ( - } label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} /> - )} - {property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && ( - - )} - {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( - - )} - {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( - - )} - {property.softFactors.passerbyFrequency && ( - - )} - {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( - - )} - - - )} - - {/* Wegzeit */} - {property.softFactors?.publicTransportMinutes != null && ( - - - - Wegzeit - - - - - - - {property.softFactors.publicTransportMinutes} Min. zu Fuss - Nächster ÖV-Anschluss — {property.location.city} - - - - Die Zeiten beziehen sich auf die Strecke zu Fuss. - - - )} - - {/* Einheiten */} - {units.length > 0 && ( - - - - Einheiten - - - {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => ( - {h} - ))} - - {preMarketUnits.map(u => )} - {otherUnits.map(u => )} - - )} - - {/* Beschreibung */} - {property.description && ( - - - - Beschreibung - - - {property.description} - - - )} - - {/* Quelle & Referenz */} - - - - Quelle & Referenz - - - {property.propertyNumber && } - {property.importedFrom && } - {property.dataQuality.lastVerifiedAt && ( - - )} - {property.sourceUrl && ( - - - - )} - - - ) - })()} + {!isFuture && property && } {reasons.length > 0 && (