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')
}