From 87ea4c4dfcbca6a704628d365e9ac17d044c0a17 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Tue, 9 Jun 2026 17:32:25 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20clear=20stale=20search=20criteria=20on?= =?UTF-8?q?=20text=20edit,=20fix=20Z=C3=BCrich=20false-positive,=20improve?= =?UTF-8?q?=20cost=20breakdown=20and=20fit-out=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/compare/CompareTableBody.tsx | 66 +++++++++++++++++-- .../match-detail/FitOutCostPanel.tsx | 5 +- .../supply/PropertyIntelligenceCard.tsx | 27 ++++++-- src/lib/constants.ts | 3 + src/pages/demand/AISearch.tsx | 3 + src/services/ai/mock/needParser.ts | 13 ++-- 6 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/components/compare/CompareTableBody.tsx b/src/components/compare/CompareTableBody.tsx index c3a8006..9a1b92e 100644 --- a/src/components/compare/CompareTableBody.tsx +++ b/src/components/compare/CompareTableBody.tsx @@ -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({ : }))} - {/* 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 + + const FIT_OUT_LABELS: Record = { + 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 ( - - + + CHF {prop.rentPricePerSqm}/m²/Jahr - {prop.totalRentMonthly && ( + + {monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete) + + {monthlyNebenkosten != null && ( - {prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat + + {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK) + + )} + {fitOutMonthlyLabel && ( + + + {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.) + + )} + + = {totalMonthly.toLocaleString('de-CH')} CHF/Monat + + {fitOutLabel && ( + + Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''} )} diff --git a/src/components/match-detail/FitOutCostPanel.tsx b/src/components/match-detail/FitOutCostPanel.tsx index ad2d35a..6ffd698 100644 --- a/src/components/match-detail/FitOutCostPanel.tsx +++ b/src/components/match-detail/FitOutCostPanel.tsx @@ -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 = { 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 ( diff --git a/src/components/supply/PropertyIntelligenceCard.tsx b/src/components/supply/PropertyIntelligenceCard.tsx index dcb7a20..f108913 100644 --- a/src/components/supply/PropertyIntelligenceCard.tsx +++ b/src/components/supply/PropertyIntelligenceCard.tsx @@ -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({ )} - {p.hardFacts?.fitOut && ( - - )} + {p.hardFacts?.fitOut && (() => { + const FIT_OUT_META: Record = { + 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 ( + + + + ) + })()} {p.contractDurationMonths && ( - + + + )} )} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 814e2f7..88f21d9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -139,3 +139,6 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record 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 = /(? lower.includes(s)) || ZURICH_KREIS_RE.test(lower) + )) { preferredLocations.push('Zürich') }