fix: clear stale search criteria on text edit, fix Zürich false-positive, improve cost breakdown and fit-out labels

- AISearch: clear parsed criteria when user manually edits text input so stale locations (e.g. Zürich) no longer persist after retyping
- needParser: fix ZURICH_SIGNALS substring bug — "kreis N" now uses word-boundary regex so "umkreis 22" no longer matches "kreis 2"
- CompareTableBody: row 9 shows full cost breakdown (Miete + NK + amortised fit-out) using FITOUT_AMORTIZATION_YEARS from constants
- FitOutCostPanel: replace local AMORTIZATION_YEARS with FITOUT_AMORTIZATION_YEARS from constants.ts (single source of truth)
- constants.ts: add FITOUT_AMORTIZATION_YEARS = 5 — change here to affect all cost calculations
- PropertyIntelligenceCard: translate raw fitOut enum to German labels (Rohbau/Grundausbau/Vollausbau/Premium-Ausbau) with explanatory tooltips; add tooltip on contract duration chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-09 17:32:25 +02:00
parent c6d3071010
commit 87ea4c4dfc
6 changed files with 97 additions and 20 deletions
+60 -6
View File
@@ -20,6 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -148,18 +150,70 @@ export function CompareTableBody({
: <MissingDataCell />
}))}
{/* 9. Rent / Budget Fit */}
{row('9. Miete / Budget', compareItems.map(item => {
{/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
{row('9. Kosten / Budget', compareItems.map(item => {
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
const monthlyNebenkosten = prop.ancillaryCosts != null
? Math.round(prop.ancillaryCosts * prop.areaSqm)
: null
const fitOut = prop.hardFacts?.fitOut
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
+ fitOutMonthly
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography>
{prop.totalRentMonthly && (
<Typography variant="caption" color="text.secondary">
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
</Typography>
{monthlyNebenkosten != null && (
<Typography variant="caption" color="text.secondary">
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
+ {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
</Typography>
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -2,12 +2,13 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = 5
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props {
@@ -69,7 +70,7 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
return (
<Paper sx={{ p: 2.5 }}>
@@ -1,5 +1,5 @@
import { memo } from 'react'
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material'
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import type { Property } from '../../domain/property'
@@ -153,13 +153,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
)}
{p.hardFacts?.fitOut && (
<Chip label={p.hardFacts.fitOut} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
)}
{p.hardFacts?.fitOut && (() => {
const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
SHELL: { label: 'Rohbau', tip: 'Keine Einbauten — volle Ausbauinvestition durch Mieter erforderlich (Böden, Decken, Trennwände, TGA).' },
BASIC: { label: 'Grundausbau', tip: 'Grundinfrastruktur vorhanden (Böden, Beleuchtung, WCs). Ausbau für Büro/Betrieb noch nötig.' },
FULL: { label: 'Vollausbau', tip: 'Bezugsfertig ausgebaut — keine Ausbauinvestition nötig. Direkt einzugsbereit.' },
PREMIUM: { label: 'Premium-Ausbau', tip: 'Hochwertig und repräsentativ ausgebaut. Sofort bezugsfertig ohne weiteren Ausbau.' },
}
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
return (
<Tooltip title={meta.tip} placement="top" arrow>
<Chip label={meta.label} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9', cursor: 'help' }} />
</Tooltip>
)
})()}
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
<Tooltip title={`Mindest-Vertragslaufzeit: ${p.contractDurationMonths} Monate (${Math.round(p.contractDurationMonths / 12)} Jahre)`} placement="top" arrow>
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569', cursor: 'help' }} />
</Tooltip>
)}
</Box>
)}
+3
View File
@@ -139,3 +139,6 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
FULL: { min: 50, max: 200 },
PREMIUM: { min: 0, max: 50 },
}
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
+3
View File
@@ -56,6 +56,9 @@ export default function AISearch() {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
}
function handleAiAutofill() {
+8 -5
View File
@@ -47,14 +47,17 @@ export function mockParseNeed(input: string): ParseNeedResult {
return re.test(lower)
}).map(([, v]) => v)
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
const ZURICH_SIGNALS = [
// Infer Zürich when Zürich-specific districts or landmarks are mentioned.
// "kreis N" patterns require a word boundary so "umkreis 22" does NOT match "kreis 2".
const ZURICH_SIGNAL_WORDS = [
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
'oerlikon', 'altstetten', 'langstrasse', 'hardbrücke', 'freilager',
'europaallee', 'zürich nord', 'zürich süd',
]
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
const ZURICH_KREIS_RE = /(?<![a-zA-ZäöüÄÖÜß])kreis\s+[1-8](?![0-9a-zA-ZäöüÄÖÜß])/
if (!preferredLocations.includes('Zürich') && (
ZURICH_SIGNAL_WORDS.some(s => lower.includes(s)) || ZURICH_KREIS_RE.test(lower)
)) {
preferredLocations.push('Zürich')
}