From a9e038a64f111c16f33cbb91a44528c91ee78982 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 22:28:04 +0200 Subject: [PATCH] feat: Marktsignale KPI panel with adjustable radius (5/10/15/20 km) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MarketKpis domain type with formula-based radius scaling, a MarketKpiPanel component (vacancy rate, platform searches, avg vacancy duration, demand/supply ratio — color-coded with interpretation badges), and integrates it at the top of PropertyMarketSignalsTab. Demand/supply section labels now update with the selected radius. Mock data covers all 5 existing market reports. Co-Authored-By: Claude Sonnet 4.6 --- src/components/supply/MarketKpiPanel.tsx | 198 ++++++++++++++++++ .../supply/PropertyMarketSignalsTab.tsx | 67 ++++-- src/domain/marketReport.ts | 24 +++ src/mock-data/propertyMarketSignals.ts | 40 ++++ 4 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 src/components/supply/MarketKpiPanel.tsx diff --git a/src/components/supply/MarketKpiPanel.tsx b/src/components/supply/MarketKpiPanel.tsx new file mode 100644 index 0000000..dfb4555 --- /dev/null +++ b/src/components/supply/MarketKpiPanel.tsx @@ -0,0 +1,198 @@ +import { useMemo } from 'react' +import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material' +import { getKpisForRadius } from '../../domain/marketReport' +import type { MarketKpis } from '../../domain/marketReport' + +interface Props { + baseKpis: MarketKpis + assetTypeLabel: string + radius: number + onRadiusChange: (r: number) => void +} + +const RADII = [5, 10, 15, 20] + +function vacancyColor(pct: number): string { + if (pct < 4) return '#1a7a4a' + if (pct < 8) return '#d97706' + return '#dc2626' +} + +function vacancyLabel(pct: number): string { + if (pct < 4) return 'Engpassmarkt' + if (pct < 8) return 'Ausgewogen' + return 'Überangebot' +} + +function searchesColor(count: number): string { + if (count >= 15) return '#1a7a4a' + if (count >= 7) return '#d97706' + return '#dc2626' +} + +function searchesLabel(count: number): string { + if (count >= 15) return 'Hohe Nachfrage' + if (count >= 7) return 'Moderate Nachfrage' + return 'Geringe Nachfrage' +} + +function durationColor(months: number): string { + if (months < 4) return '#1a7a4a' + if (months < 7) return '#d97706' + return '#dc2626' +} + +function durationLabel(months: number): string { + if (months < 4) return 'Schnell absorbiert' + if (months < 7) return 'Moderat' + return 'Träger Markt' +} + +function ratioColor(ratio: number): string { + if (ratio >= 2) return '#1a7a4a' + if (ratio >= 1) return '#d97706' + return '#dc2626' +} + +function ratioLabel(ratio: number): string { + if (ratio >= 2) return 'Nachfrageüberhang' + if (ratio >= 1) return 'Leicht ausgeglichen' + return 'Angebotsüberhang' +} + +interface KpiCardProps { + label: string + value: string + badge: string + badgeColor: string +} + +function KpiCard({ label, value, badge, badgeColor }: KpiCardProps) { + return ( + + + {label} + + + {value} + + + + {badge} + + + + ) +} + +export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) { + const kpis = useMemo( + () => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius), + [baseKpis, radius] + ) + + return ( + + + + + Marktumfeld — {assetTypeLabel} + + + Vergleichbare Flächen im Umkreis + + + v !== null && onRadiusChange(v)} + size="small" + sx={{ + '& .MuiToggleButton-root': { + py: 0.25, + px: 0.875, + fontSize: '0.7rem', + textTransform: 'none', + fontWeight: 600, + border: '1px solid #e2e8f0', + color: '#64748b', + '&.Mui-selected': { + bgcolor: '#1e3a5f', + color: '#ffffff', + borderColor: '#1e3a5f', + '&:hover': { bgcolor: '#1e3a5f' }, + }, + }, + }} + > + {RADII.map(r => ( + + {r} km + + ))} + + + + + + + + + + + + Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung + + + ) +} diff --git a/src/components/supply/PropertyMarketSignalsTab.tsx b/src/components/supply/PropertyMarketSignalsTab.tsx index 6a247d3..aa266ca 100644 --- a/src/components/supply/PropertyMarketSignalsTab.tsx +++ b/src/components/supply/PropertyMarketSignalsTab.tsx @@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport' import type { SignalCategory } from '../../domain/marketReport' import { SignalCard } from './SignalCard' import { BerichtDialog } from './BerichtDialog' +import { MarketKpiPanel } from './MarketKpiPanel' interface Props { propertyId: string } -const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [ +const STATIC_SECTIONS: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [ { category: 'development', label: 'Entwicklungsnews', icon: , color: '#1e3a5f' }, - { category: 'demand', label: 'Nachfrage 5 km', icon: , color: '#1a7a4a' }, - { category: 'supply', label: 'Angebot 5 km', icon: , color: '#b45309' }, { category: 'negotiation', label: 'Verhandlungshinweise', icon: , color: '#7c3aed' }, ] export function PropertyMarketSignalsTab({ propertyId }: Props) { const { data: report = null, isLoading: loading } = useMarketReport(propertyId) const [dialogOpen, setDialogOpen] = useState(false) + const [radius, setRadius] = useState(5) + + const dynamicSections: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [ + { category: 'demand', label: `Nachfrage ${radius} km`, icon: , color: '#1a7a4a' }, + { category: 'supply', label: `Angebot ${radius} km`, icon: , color: '#b45309' }, + ] + + const sectionConfig = [ + STATIC_SECTIONS[0], + dynamicSections[0], + dynamicSections[1], + STATIC_SECTIONS[1], + ] if (loading) { return ( @@ -58,26 +70,37 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) { Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor. ) : ( - SECTION_CONFIG.map(({ category, label, icon, color }) => { - const signals = report.signals.filter(s => s.category === category) - if (signals.length === 0) return null - return ( - - - {icon} - - {label} - - + <> + {report.kpis && ( + + )} + + {sectionConfig.map(({ category, label, icon, color }) => { + const signals = report.signals.filter(s => s.category === category) + if (signals.length === 0) return null + return ( + + + {icon} + + {label} + + + + {signals.map(s => )} - {signals.map(s => )} - - ) - }) + ) + })} + )} {dialogOpen && ( diff --git a/src/domain/marketReport.ts b/src/domain/marketReport.ts index 8425ab2..6dc0c5d 100644 --- a/src/domain/marketReport.ts +++ b/src/domain/marketReport.ts @@ -11,8 +11,32 @@ export interface PropertyMarketSignal { confidence?: number } +// Key market performance indicators for a given radius. +// Base values are stored at 5 km; other radii are computed via getKpisForRadius(). +export interface MarketKpis { + vacancyRatePct: number // % of comparable properties currently vacant + activePlatformSearches: number // active search requests on the platform + avgVacancyMonths: number // average months comparable properties remain vacant + demandSupplyRatio: number // demand / supply ratio (> 1 = more demand than supply) + comparableListings: number // number of comparable active listings +} + +/** Scale base KPIs (at 5 km) for a larger radius. */ +export function getKpisForRadius(base: MarketKpis, radiusKm: number): MarketKpis { + const f = radiusKm / 5 + return { + vacancyRatePct: +Math.min(35, base.vacancyRatePct + (f - 1) * 1.8).toFixed(1), + activePlatformSearches: Math.round(base.activePlatformSearches * f * 0.85), + avgVacancyMonths: +Math.min(24, base.avgVacancyMonths * (1 + (f - 1) * 0.12)).toFixed(1), + demandSupplyRatio: +Math.max(0.3, base.demandSupplyRatio / (1 + (f - 1) * 0.18)).toFixed(1), + comparableListings: Math.round(base.comparableListings * f * 1.1), + } +} + export interface MarketReport { propertyId: string generatedAt: string + assetTypeLabel: string // e.g. "Büro" + kpis: MarketKpis // base values at 5 km radius signals: PropertyMarketSignal[] } diff --git a/src/mock-data/propertyMarketSignals.ts b/src/mock-data/propertyMarketSignals.ts index 7d0b2fb..c9a3349 100644 --- a/src/mock-data/propertyMarketSignals.ts +++ b/src/mock-data/propertyMarketSignals.ts @@ -4,6 +4,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [ { propertyId: 'prop-001', generatedAt: '2025-05-18T08:00:00Z', + assetTypeLabel: 'Büro', + kpis: { + vacancyRatePct: 4.2, + activePlatformSearches: 14, + avgVacancyMonths: 4.2, + demandSupplyRatio: 2.3, + comparableListings: 3, + }, signals: [ { id: 'sig-001-1', @@ -44,6 +52,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [ { propertyId: 'prop-007', generatedAt: '2025-05-18T08:00:00Z', + assetTypeLabel: 'Büro', + kpis: { + vacancyRatePct: 3.8, + activePlatformSearches: 11, + avgVacancyMonths: 3.5, + demandSupplyRatio: 2.8, + comparableListings: 1, + }, signals: [ { id: 'sig-007-1', @@ -83,6 +99,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [ { propertyId: 'prop-008', generatedAt: '2025-05-18T08:00:00Z', + assetTypeLabel: 'Büro', + kpis: { + vacancyRatePct: 7.1, + activePlatformSearches: 7, + avgVacancyMonths: 6.0, + demandSupplyRatio: 1.4, + comparableListings: 4, + }, signals: [ { id: 'sig-008-1', @@ -123,6 +147,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [ { propertyId: 'prop-012', generatedAt: '2025-05-18T08:00:00Z', + assetTypeLabel: 'Büro', + kpis: { + vacancyRatePct: 2.9, + activePlatformSearches: 22, + avgVacancyMonths: 2.8, + demandSupplyRatio: 3.4, + comparableListings: 2, + }, signals: [ { id: 'sig-012-1', @@ -172,6 +204,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [ { propertyId: 'prop-014', generatedAt: '2025-05-18T08:00:00Z', + assetTypeLabel: 'Lager / Logistik', + kpis: { + vacancyRatePct: 5.5, + activePlatformSearches: 11, + avgVacancyMonths: 5.8, + demandSupplyRatio: 1.9, + comparableListings: 2, + }, signals: [ { id: 'sig-014-1',