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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,7 +70,17 @@ 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 }) => {
|
<>
|
||||||
|
{report.kpis && (
|
||||||
|
<MarketKpiPanel
|
||||||
|
baseKpis={report.kpis}
|
||||||
|
assetTypeLabel={report.assetTypeLabel}
|
||||||
|
radius={radius}
|
||||||
|
onRadiusChange={setRadius}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sectionConfig.map(({ category, label, icon, color }) => {
|
||||||
const signals = report.signals.filter(s => s.category === category)
|
const signals = report.signals.filter(s => s.category === category)
|
||||||
if (signals.length === 0) return null
|
if (signals.length === 0) return null
|
||||||
return (
|
return (
|
||||||
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
|||||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user