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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user