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
+29 -2
View File
@@ -7,8 +7,8 @@ import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { NextActionsPanel } from '../../components/match-detail'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -72,6 +72,9 @@ export default function MatchDetail() {
const cityIntel = city ? getCityIntelligence(city) : null
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
@@ -111,6 +114,15 @@ export default function MatchDetail() {
{ label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '' },
{ label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ label: 'Nutzungsart', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab} MAB` : base
})(),
}] : []),
]
return (
@@ -152,6 +164,10 @@ export default function MatchDetail() {
keyFacts={keyFacts}
onCompare={handleCompare}
onShortlist={handleShortlist}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/>
{/* ── Main content ── */}
@@ -197,6 +213,17 @@ export default function MatchDetail() {
})}
/>
{/* ── Section C: Reale Jahresbelastung (alle fitOut-Werte) ── */}
{!isFuture && property?.hardFacts?.fitOut && (
<FitOutCostPanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
/>
)}
{/* ── Full analysis toggle + expanded panels ── */}
<MatchDetailScoreBreakdown
match={match}
+33 -1
View File
@@ -1,5 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
@@ -161,6 +161,29 @@ export default function Results() {
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.searchRadius && (
<Typography variant="caption" color="text.secondary">
<strong>Radius:</strong> {activeNeed.searchRadius} km
</Typography>
)}
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
<Typography variant="caption" color="text.secondary">
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
</Typography>
)}
{activeNeed.requiredFitOut && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaustandard:</strong> min. {
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
}
</Typography>
)}
{activeNeed.fitOutBudgetMaxPerSqm && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
@@ -168,6 +191,14 @@ export default function Results() {
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{activeNeed.isAnonymous && (
<Chip
label="Anonyme Suche"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
/>
)}
<Button
size="small"
variant="text"
@@ -176,6 +207,7 @@ export default function Results() {
>
Suche ändern
</Button>
</Box>
</Box>
</Card>
)}
+3
View File
@@ -83,6 +83,9 @@ export default function NewListing() {
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
isFlexible={form.isFlexible} onIsFlexibleChange={form.setIsFlexible}
minLettableSqm={form.minLettableSqm} onMinLettableSqmChange={form.setMinLettableSqm}
/>
<ImageUrlSection
+15
View File
@@ -19,11 +19,15 @@ export function buildCreatePropertyInput(fields: {
ceilingHeight: string
images: string[]
floorPlanUrl: string
mieterausbaubeitrag?: string
isFlexible?: boolean
minLettableSqm?: string
}): CreatePropertyInput {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
const sf = {
@@ -41,6 +45,7 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
@@ -76,5 +81,15 @@ export function buildCreatePropertyInput(fields: {
warnings: [],
},
status: 'ACTIVE',
units: isFlexible ? [{
id: `unit-listing-${Date.now()}`,
propertyId: '',
floorLevel: hf.floor ?? 0,
areaSqm,
available: true,
rentPricePerSqm: rentPerSqm,
isFlexible: true,
minLettableSqm: minLettableSqm ? parseInt(minLettableSqm) : undefined,
}] : undefined,
}
}