Files
property-match/src/components/match-detail/LocationIntelligencePanel.tsx
T
Benjamin Sutter f487435a94 fix: correct rent unit labels and monthly/annual calculation consistency
- NeedAlignmentPanel: display budget row in monthly (CHF/m²/Mt.) to match
  the Preis section, use exact division (no Math.round) to avoid 13×12≠152
- MatchDetailPropertySections + PropertyDetailPublicSections: replace
  Math.round(rentPricePerSqm/12) with exact division; show 2 decimal places
  when monthly is not a whole number (e.g. CHF 12.67 instead of CHF 13)
- Add /Jahr suffix to all 15 displays showing rentPricePerSqm or maxPerSqm
  without a time unit across results, compare, match-detail, supply, and
  anfragencenter components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:35:20 +02:00

278 lines
13 KiB
TypeScript

import { Box, Chip, Divider, Paper, Typography } from '@mui/material'
import {
Activity, Building2, HardHat, MapPin, Percent,
TrendingDown, TrendingUp, Train, Users, Zap,
} from 'lucide-react'
import { useNavigate } from 'react-router'
import { useProperties } from '../../hooks/useProperties'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { SoftFactorBar } from './SoftFactorBar'
import { KpiTile } from './KpiTile'
import { NEW_PROJECTS } from './locationIntelligenceConstants'
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
property: Property | null
}
export function LocationIntelligencePanel({ property }: Props) {
const navigate = useNavigate()
const { data: allProperties = [] } = useProperties()
if (!property) return null
const city = property.location.city
const intel = getCityIntelligence(city)
const sf = property.softFactors
const hasSoftFactors = sf && (
sf.footfallScore !== undefined ||
sf.taxEnvironmentScore !== undefined ||
sf.commuterAccessScore !== undefined ||
sf.talentAccessScore !== undefined ||
sf.prestigeScore !== undefined ||
sf.prestige !== undefined
)
// Market comparables: same type, same city, different property
const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === city)
.sort((a, b) => b.confidenceScore - a.confidenceScore)
.slice(0, 3)
const newProjects = NEW_PROJECTS[city] ?? []
const rentTrendPositive = intel && intel.rentTrend12m > 0
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.25 }}>
Standort-Intelligence
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wirtschaftliche Faktoren und Marktkontext über klassische Flächenangaben hinaus
</Typography>
{/* ── City KPIs ── */}
{intel && (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<KpiTile
label="Leerstandsquote"
value={`${intel.vacancyRatePct}%`}
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
color={intel.vacancyRatePct < 3 ? DS_TEXT.error : intel.vacancyRatePct < 5 ? DS_TEXT.warning : DS_TEXT.success}
/>
<KpiTile
label="Mietpreis-Trend"
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
sub="letzte 12 Monate"
color={rentTrendPositive ? DS_TEXT.error : DS_TEXT.success}
/>
<KpiTile
label="Kaufkraft-Index"
value={`${intel.purchasingPowerIndex}`}
sub="CH-Mittel = 100"
color={intel.purchasingPowerIndex >= 115 ? DS_TEXT.success : intel.purchasingPowerIndex >= 95 ? DS_TEXT.warning : DS_TEXT.error}
/>
<KpiTile
label="Ø Vermietungsdauer"
value={`${intel.avgDaysOnMarket}T`}
sub="Tage auf dem Markt"
color={intel.avgDaysOnMarket < 40 ? DS_TEXT.error : intel.avgDaysOnMarket < 65 ? DS_TEXT.warning : DS_TEXT.success}
/>
</Box>
{/* Rent trend interpretation */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : DS_SURFACE.success.bg, borderRadius: 1.5, mb: 2 }}>
<Box sx={{ color: rentTrendPositive ? DS_TEXT.warning : DS_TEXT.success, mt: 0.1 }}>
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
</Box>
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
{rentTrendPositive
? `Mietpreise in ${city} sind in den letzten 12 Monaten um ${intel.rentTrend12m}% gestiegen. Frühzeitig abschliessen kann vorteilhaft sein.`
: `Mietpreise in ${city} sind leicht rückläufig. Verhandlungsspielraum nutzen.`}
</Typography>
</Box>
{/* Tax + demand */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Percent size={13} color={DS_TEXT.muted} />
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? DS_TEXT.success : intel.taxIndexCanton <= 105 ? DS_TEXT.warning : DS_TEXT.error }}>
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Activity size={13} color={DS_TEXT.muted} />
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
</Box>
<Chip
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
size="small"
sx={{
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: DS_SURFACE.success.bg }[intel.demandStrength],
color: { LOW: DS_TEXT.muted, MEDIUM: DS_TEXT.warning, HIGH: '#16a34a', VERY_HIGH: DS_TEXT.success }[intel.demandStrength],
fontWeight: 600, fontSize: 11,
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Aktive Nachfrage in {city}
</Typography>
</Box>
</Box>
{/* Industry clusters */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
Dominante Branchen-Cluster
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: DS_TEXT.brand, fontSize: 11, height: 22 }} />
))}
</Box>
</Box>
{/* Infrastructure */}
{intel.plannedInfrastructure.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Train size={13} color={DS_TEXT.muted} />
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
Geplante Infrastruktur-Projekte
</Typography>
</Box>
{intel.plannedInfrastructure.map((proj, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75, pl: 1.5 }}>
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, whiteSpace: 'nowrap' }}>
{proj.timeline}
</Typography>
<Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{proj.project}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.impact}</Typography>
</Box>
</Box>
))}
</Box>
)}
<Divider sx={{ my: 1.5 }} />
</>
)}
{/* ── Soft Factors ── */}
{hasSoftFactors && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 1 }}>
KI-berechnete Standortqualität
</Typography>
<SoftFactorBar
label="Passantenfrequenz"
value={sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] : undefined)}
icon={<Users size={14} />}
tooltip="Geschätzte Personenfrequenz im Umfeld — relevant für Retail & Sichtbarkeit"
/>
<SoftFactorBar
label="ÖV-Anbindung"
value={sf.commuterAccessScore}
icon={<Train size={14} />}
tooltip={sf.publicTransportMinutes ? `~${sf.publicTransportMinutes} Min. zum nächsten ÖV-Hub` : 'Öffentliche Erreichbarkeit des Standorts'}
/>
<SoftFactorBar
label="Talentpool-Zugang"
value={sf.talentAccessScore ?? (sf.talentAccess as number | undefined)}
icon={<Users size={14} />}
tooltip="Verfügbarkeit qualifizierter Fachkräfte in einem 30-Min.-Radius"
/>
<SoftFactorBar
label="Prestige / Lagequalität"
value={sf.prestigeScore ?? (sf.prestige as number | undefined)}
icon={<MapPin size={14} />}
tooltip="Adress-Prestige und wahrgenommene Standortqualität"
/>
<SoftFactorBar
label="Flexibilitätspotenzial"
value={sf.flexibilityScore}
icon={<Zap size={14} />}
tooltip="Möglichkeit zur Flächen-Anpassung (Ausbau, Teilung, Erweiterung)"
/>
{sf.esgScore !== undefined && (
<SoftFactorBar
label="ESG-Bewertung"
value={sf.esgScore}
icon={<Activity size={14} />}
tooltip="Umwelt-, Sozial- und Governance-Standard des Gebäudes"
/>
)}
</Box>
)}
{/* ── Market Comparables ── */}
{comparables.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
Vergleichbare Angebote in {city}
</Typography>
{comparables.map(p => (
<Box
key={p.id}
onClick={() => navigate(`/demand/property/${p.id}`)}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75,
borderBottom: '1px solid #f1f5f9', cursor: 'pointer',
'&:hover': { bgcolor: DS_SURFACE.neutral.bg, borderRadius: 0.5 },
}}
>
<Building2 size={13} color="#3b82f6" />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#1d4ed8' }}>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²/Jahr
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
</Typography>
</Box>
</Box>
))}
</>
)}
{/* ── New Construction ── */}
{newProjects.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#7c3aed', display: 'block', mb: 0.75 }}>
Neubauprojekte als Alternative
</Typography>
{newProjects.map((proj, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
<HardHat size={13} color="#7c3aed" />
<Box>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block' }}>{proj.title}</Typography>
<Typography variant="caption" color="text.secondary">
{proj.area} · Fertigstellung {proj.completion}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.note}</Typography>
</Box>
</Box>
))}
</>
)}
</Paper>
)
}