Files
property-match/src/components/supply/MarketKpiPanel.tsx
T
Benjamin Sutter a9e038a64f 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>
2026-05-24 22:28:04 +02:00

199 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}