feat: Marktsignale KPI panel with adjustable radius (5/10/15/20 km)

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 22:28:04 +02:00
parent 61a26d00a7
commit a9e038a64f
4 changed files with 307 additions and 22 deletions
+198
View File
@@ -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 (
<Box
sx={{
flex: 1,
minWidth: 0,
p: 1.5,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: '#ffffff',
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500, fontSize: '0.7rem', lineHeight: 1.2 }}>
{label}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#0f172a', lineHeight: 1 }}>
{value}
</Typography>
<Box
sx={{
display: 'inline-block',
px: 0.75,
py: 0.25,
borderRadius: 0.75,
bgcolor: `${badgeColor}18`,
alignSelf: 'flex-start',
mt: 0.25,
}}
>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: badgeColor, lineHeight: 1.4 }}>
{badge}
</Typography>
</Box>
</Box>
)
}
export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) {
const kpis = useMemo(
() => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius),
[baseKpis, radius]
)
return (
<Box
sx={{
mb: 2.5,
p: 1.75,
border: '1px solid #e2e8f0',
borderRadius: 2,
bgcolor: '#f8fafc',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
Marktumfeld {assetTypeLabel}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Vergleichbare Flächen im Umkreis
</Typography>
</Box>
<ToggleButtonGroup
value={radius}
exclusive
onChange={(_, v) => 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 => (
<ToggleButton key={r} value={r}>
{r} km
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<KpiCard
label="Leerstandsquote"
value={`${kpis.vacancyRatePct.toFixed(1)} %`}
badge={vacancyLabel(kpis.vacancyRatePct)}
badgeColor={vacancyColor(kpis.vacancyRatePct)}
/>
<KpiCard
label="Aktive Suchanfragen"
value={String(kpis.activePlatformSearches)}
badge={searchesLabel(kpis.activePlatformSearches)}
badgeColor={searchesColor(kpis.activePlatformSearches)}
/>
<KpiCard
label="Ø Leerstandsdauer"
value={`${kpis.avgVacancyMonths.toFixed(1)} Mo.`}
badge={durationLabel(kpis.avgVacancyMonths)}
badgeColor={durationColor(kpis.avgVacancyMonths)}
/>
<KpiCard
label="Nachfrage / Angebot"
value={`${kpis.demandSupplyRatio.toFixed(1)}×`}
badge={ratioLabel(kpis.demandSupplyRatio)}
badgeColor={ratioColor(kpis.demandSupplyRatio)}
/>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', mt: 1, display: 'block' }}>
Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung
</Typography>
</Box>
)
}
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
import type { SignalCategory } from '../../domain/marketReport' import type { SignalCategory } from '../../domain/marketReport'
import { SignalCard } from './SignalCard' import { SignalCard } from './SignalCard'
import { BerichtDialog } from './BerichtDialog' import { BerichtDialog } from './BerichtDialog'
import { MarketKpiPanel } from './MarketKpiPanel'
interface Props { interface Props {
propertyId: string 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: <Building2 size={16} />, color: '#1e3a5f' }, { category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' }, { category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
] ]
export function PropertyMarketSignalsTab({ propertyId }: Props) { export function PropertyMarketSignalsTab({ propertyId }: Props) {
const { data: report = null, isLoading: loading } = useMarketReport(propertyId) const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
const [dialogOpen, setDialogOpen] = useState(false) 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: <TrendingUp size={16} />, color: '#1a7a4a' },
{ category: 'supply', label: `Angebot ${radius} km`, icon: <BarChart2 size={16} />, color: '#b45309' },
]
const sectionConfig = [
STATIC_SECTIONS[0],
dynamicSections[0],
dynamicSections[1],
STATIC_SECTIONS[1],
]
if (loading) { if (loading) {
return ( return (
@@ -58,26 +70,37 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor. Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
</Alert> </Alert>
) : ( ) : (
SECTION_CONFIG.map(({ category, label, icon, color }) => { <>
const signals = report.signals.filter(s => s.category === category) {report.kpis && (
if (signals.length === 0) return null <MarketKpiPanel
return ( baseKpis={report.kpis}
<Box key={category} sx={{ mb: 3 }}> assetTypeLabel={report.assetTypeLabel}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}> radius={radius}
<Box sx={{ color }}>{icon}</Box> onRadiusChange={setRadius}
<Typography variant="body2" sx={{ fontWeight: 700, color }}> />
{label} )}
</Typography>
<Chip {sectionConfig.map(({ category, label, icon, color }) => {
label={signals.length} const signals = report.signals.filter(s => s.category === category)
size="small" if (signals.length === 0) return null
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }} return (
/> <Box key={category} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Box sx={{ color }}>{icon}</Box>
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
{label}
</Typography>
<Chip
label={signals.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }}
/>
</Box>
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
</Box> </Box>
{signals.map(s => <SignalCard key={s.id} signal={s} />)} )
</Box> })}
) </>
})
)} )}
{dialogOpen && ( {dialogOpen && (
+24
View File
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
confidence?: number 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 { export interface MarketReport {
propertyId: string propertyId: string
generatedAt: string generatedAt: string
assetTypeLabel: string // e.g. "Büro"
kpis: MarketKpis // base values at 5 km radius
signals: PropertyMarketSignal[] signals: PropertyMarketSignal[]
} }
+40
View File
@@ -4,6 +4,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-001', propertyId: 'prop-001',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 4.2,
activePlatformSearches: 14,
avgVacancyMonths: 4.2,
demandSupplyRatio: 2.3,
comparableListings: 3,
},
signals: [ signals: [
{ {
id: 'sig-001-1', id: 'sig-001-1',
@@ -44,6 +52,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-007', propertyId: 'prop-007',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 3.8,
activePlatformSearches: 11,
avgVacancyMonths: 3.5,
demandSupplyRatio: 2.8,
comparableListings: 1,
},
signals: [ signals: [
{ {
id: 'sig-007-1', id: 'sig-007-1',
@@ -83,6 +99,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-008', propertyId: 'prop-008',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 7.1,
activePlatformSearches: 7,
avgVacancyMonths: 6.0,
demandSupplyRatio: 1.4,
comparableListings: 4,
},
signals: [ signals: [
{ {
id: 'sig-008-1', id: 'sig-008-1',
@@ -123,6 +147,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-012', propertyId: 'prop-012',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 2.9,
activePlatformSearches: 22,
avgVacancyMonths: 2.8,
demandSupplyRatio: 3.4,
comparableListings: 2,
},
signals: [ signals: [
{ {
id: 'sig-012-1', id: 'sig-012-1',
@@ -172,6 +204,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-014', propertyId: 'prop-014',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Lager / Logistik',
kpis: {
vacancyRatePct: 5.5,
activePlatformSearches: 11,
avgVacancyMonths: 5.8,
demandSupplyRatio: 1.9,
comparableListings: 2,
},
signals: [ signals: [
{ {
id: 'sig-014-1', id: 'sig-014-1',