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',