feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage

Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-06 20:09:55 +02:00
parent a825672197
commit b2530f9e20
40 changed files with 1755 additions and 733 deletions
+77 -16
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { Box } from '@mui/material'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
import { Box, Typography } from '@mui/material'
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})
const searchIcon = L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:#7c3aed;border:3px solid white;box-shadow:0 2px 6px rgba(124,58,237,0.5)"></div>`,
iconAnchor: [7, 7],
})
interface Props {
lat: number
lng: number
label?: string
height?: number
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
}
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
return null
}
export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
propLat: number; propLng: number
searchLat: number; searchLng: number; radiusKm: number
}) {
const map = useMap()
useEffect(() => {
const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
const bounds = circleBounds.extend([propLat, propLng])
map.fitBounds(bounds, { padding: [30, 30] })
}, [map, propLat, propLng, searchLat, searchLng, radiusKm])
return null
}
export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
return (
<Box
sx={{
height,
width: '100%',
overflow: 'hidden',
flexShrink: 0,
// Ensure leaflet controls appear above MUI elements
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}
>
<Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}>
<MapContainer
center={[lat, lng]}
zoom={14}
zoom={hasSearch ? 10 : 14}
scrollWheelZoom
style={{ height: '100%', width: '100%' }}
>
<RecenterOnChange lat={lat} lng={lng} />
{hasSearch
? <FitToRadius propLat={lat} propLng={lng} searchLat={searchCenterLat!} searchLng={searchCenterLng!} radiusKm={searchRadiusKm!} />
: <RecenterOnChange lat={lat} lng={lng} />
}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Search radius circle */}
{hasSearch && (
<Circle
center={[searchCenterLat!, searchCenterLng!]}
radius={searchRadiusKm! * 1000}
pathOptions={{ color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: 0.07, weight: 2, dashArray: '6 4' }}
/>
)}
{/* Search center marker */}
{hasSearch && (
<Marker position={[searchCenterLat!, searchCenterLng!]} icon={searchIcon}>
<Popup>{searchLabel ?? `Suchradius: ${searchRadiusKm} km`}</Popup>
</Marker>
)}
{/* Property marker */}
<Marker position={[lat, lng]}>
{label && <Popup>{label}</Popup>}
</Marker>
</MapContainer>
{/* Legend overlay */}
{hasSearch && (
<Box sx={{
position: 'absolute', bottom: 8, left: 8, zIndex: 500,
bgcolor: 'rgba(255,255,255,0.92)', borderRadius: 1.5,
px: 1.25, py: 0.625, display: 'flex', flexDirection: 'column', gap: 0.4,
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#7c3aed', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#5b21b6', fontWeight: 700 }}>
{searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
</Box>
</Box>
)}
</Box>
)
}