diff --git a/src/components/compare/CompareTableBody.tsx b/src/components/compare/CompareTableBody.tsx
index ba8edef..0c462ef 100644
--- a/src/components/compare/CompareTableBody.tsx
+++ b/src/components/compare/CompareTableBody.tsx
@@ -20,8 +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 { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
+import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
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 fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
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')}`
- }
- }
+ // Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
+ const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
+ fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
+ })
+ const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
+ const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
- + {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ + {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
)}
@@ -213,7 +200,7 @@ export function CompareTableBody({
{fitOutLabel && (
- Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
+ Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
)}
diff --git a/src/components/match-detail/FitOutAdvicePanel.tsx b/src/components/match-detail/FitOutAdvicePanel.tsx
new file mode 100644
index 0000000..f929fc7
--- /dev/null
+++ b/src/components/match-detail/FitOutAdvicePanel.tsx
@@ -0,0 +1,82 @@
+import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
+import { Lightbulb, MessageSquareQuote } from 'lucide-react'
+import { useFitOutAdvice } from '../../hooks/useAI'
+import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
+
+const RECOMMENDATION_META: Record = {
+ MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
+ BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
+ MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
+}
+
+interface Props {
+ fitOut: string
+ areaSqm: number
+ mabPerSqm: number
+ rentPricePerSqm: number
+ requiredFitOut?: string
+ tenantBudgetPerSqm?: number
+}
+
+export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
+ const { data, isLoading, isError } = useFitOutAdvice({
+ fitOut,
+ areaSqm,
+ mabPerSqm,
+ requiredFitOut,
+ tenantBudgetPerSqm,
+ monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
+ })
+
+ // Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
+ if (isError) return null
+
+ if (isLoading || !data) {
+ return (
+
+
+ KI-Ausbauempfehlung wird erstellt…
+
+ )
+ }
+
+ const advice = data.data
+ const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
+
+ return (
+
+
+
+ KI-Ausbauempfehlung
+
+
+
+ {advice.headline}
+
+ {advice.explanation}
+
+
+
+ Geschätzte Nettoinvestition
+ {advice.estimatedNetInvestment}
+
+
+
+
+
+ Verhandlungstipp: {advice.negotiationTip}
+
+
+
+ {data.provenance?.fallbackUsed && (
+
+ Hinweis: KI nicht verfügbar — Richtwert-basierte Empfehlung.
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/FitOutCostPanel.tsx b/src/components/match-detail/FitOutCostPanel.tsx
index 6ffd698..4dcc5a0 100644
--- a/src/components/match-detail/FitOutCostPanel.tsx
+++ b/src/components/match-detail/FitOutCostPanel.tsx
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
-import { calcFitOutInvestment } from '../../lib/fitOutUtils'
+import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
-import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
+import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
-const FIT_OUT_LABELS: Record = {
- SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
-}
-
-const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
-const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
+const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
- tenantBudgetPerSqm?: number
+ fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
}
-function chfRange(min: number, max: number): string {
- if (Math.round(min) === Math.round(max)) return chf(min)
- return `${chf(min)} – ${chf(max)}`
-}
-
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
-export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
+export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
+ const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
- const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
+ const fitOutPerYear = fitOutPerSqm * areaSqm
+ const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
- const investment = isReadyToMoveIn
- ? null
- : calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
-
- const fitOutPerYear = investment && !investment.isFullyCovered ? {
- min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
- max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
- } : { min: 0, max: 0 }
-
- const totalPerYear = {
- min: rentPerYear + fitOutPerYear.min,
- max: rentPerYear + fitOutPerYear.max,
- }
- const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
- const disclaimer = isReadyToMoveIn
- ? null
- : `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
+ // Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
+ const hasSurcharge = fitOutPerYear > 0
+ const isWarning = totalPerYear > rentPerYear * 1.3
+ const disclaimer = hasSurcharge
+ ? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
+ : null
return (
Reale Jahresbelastung
- {!isReadyToMoveIn && (
+ {hasSurcharge && (
- {isReadyToMoveIn ? (
+ {fitOutByLandlord ? (
+
+ ) : !hasSurcharge ? (
- ) : investment?.isFullyCovered ? (
-
) : (
0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
- ` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
+ ` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
}
/>
)}
diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts
index 67f8ca0..de3be04 100644
--- a/src/components/match-detail/index.ts
+++ b/src/components/match-detail/index.ts
@@ -13,4 +13,5 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
+export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
diff --git a/src/components/new-listing/TechnicalDetailsSection.tsx b/src/components/new-listing/TechnicalDetailsSection.tsx
index 02505c3..ff3b843 100644
--- a/src/components/new-listing/TechnicalDetailsSection.tsx
+++ b/src/components/new-listing/TechnicalDetailsSection.tsx
@@ -1,11 +1,16 @@
-import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
+import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
+// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
+const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
+
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
+ fitOutByLandlord: boolean
+ onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
+ fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
+ const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
Technische Details (optional)
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
))}
- onMieterausbaubeitragChange(e.target.value)}
- size="small" type="number" fullWidth
- slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
- helperText="Beitrag des Vermieters"
- />
onParkingChange(e.target.value)}
@@ -64,6 +63,40 @@ export function TechnicalDetailsSection({
/>
+ {/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
+ {showFitOutResponsibility && (
+
+ Wer baut aus?
+
+ { if (v) onFitOutByLandlordChange(v === 'landlord') }}
+ >
+ Vermieter übernimmt
+ Mieter baut aus
+
+
+ {fitOutByLandlord ? (
+
+ Ausbau im Mietzins enthalten — bitte die bezugsfertige Miete eintragen.
+ Es wird kein Aufschlag berechnet.
+
+ ) : (
+ onMieterausbaubeitragChange(e.target.value)}
+ size="small" type="number" sx={{ width: 240 }}
+ slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
+ helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
+ />
+ )}
+
+
+ )}
+
{/* Divisibility */}
{
expect(output.excludedReason).toBeTruthy()
})
+ // ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
+ function budgetFactor(need: ReturnType, prop: ReturnType) {
+ return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
+ }
+
+ it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
+ const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
+ const base = { rentPricePerSqm: 48, areaSqm: 300 }
+ const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
+ const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
+ expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
+ })
+
+ it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
+ const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
+ const base = { rentPricePerSqm: 48, areaSqm: 300 }
+ const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
+ const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
+ expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
+ })
+
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed()
const prop = makeProperty()
diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts
index 67a4803..dc0ad57 100644
--- a/src/features/matching/scoreCalculator.ts
+++ b/src/features/matching/scoreCalculator.ts
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
+import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
- const rent = property.rentPricePerSqm
+
+ // Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
+ const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
+ fitOut: property.hardFacts?.fitOut,
+ rentPricePerSqm: property.rentPricePerSqm,
+ mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
+ fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
+ })
+ // Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
+ const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
let score: number
let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
- explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
+ explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
- explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
+ explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
- explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
+ explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else {
score = 20
- explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
+ explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
}
}
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
- // ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
- const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
+ // ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
+ // abzgl. severePenalty, clamped 0–100 (Trust-first: schwache Datenlage senkt den Score) ──
+ const dataQualityModifier = calcDataQualityModifier(property)
+ const confidenceModifier = calcConfidenceModifier(property)
+ const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
+ + dataQualityModifier + confidenceModifier - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
- dataQualityModifier: 0,
- confidenceModifier: 0,
+ dataQualityModifier,
+ confidenceModifier,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
diff --git a/src/hooks/useAI.ts b/src/hooks/useAI.ts
index aa478e5..c2efa6c 100644
--- a/src/hooks/useAI.ts
+++ b/src/hooks/useAI.ts
@@ -1,6 +1,6 @@
-import { useMutation } from '@tanstack/react-query'
+import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
-import type { OfferEmailPayload } from '../services/aiService'
+import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -25,3 +25,12 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text),
})
}
+
+export function useFitOutAdvice(input: FitOutAdviceInput | null) {
+ return useQuery({
+ queryKey: ['fitOutAdvice', input],
+ queryFn: () => aiService.generateFitOutAdvice(input!),
+ enabled: !!input,
+ staleTime: Infinity,
+ })
+}
diff --git a/src/hooks/useNewListingForm.ts b/src/hooks/useNewListingForm.ts
index 19cecd6..a5cde66 100644
--- a/src/hooks/useNewListingForm.ts
+++ b/src/hooks/useNewListingForm.ts
@@ -43,6 +43,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
+ fitOutByLandlord: boolean
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -80,6 +81,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
+ setFitOutByLandlord: (v: boolean) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +119,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
+ const [fitOutByLandlord, setFitOutByLandlord] = useState(pre.fitOutByLandlord ?? false)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
@@ -169,7 +172,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
- floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
+ floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
@@ -185,7 +188,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
- setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
+ setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
@@ -195,7 +198,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
- softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
+ softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -205,7 +208,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
- setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
+ setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 88f21d9..bf91047 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record = {
+ SHELL: 'Rohbau · Core & Shell',
+ BASIC: 'Edelrohbau · CAT A',
+ FULL: 'Ausgebaut · CAT B',
+ PREMIUM: 'Vollausgebaut · Plug & Play',
+}
diff --git a/src/lib/fitOutUtils.ts b/src/lib/fitOutUtils.ts
index 03f11b4..5424a13 100644
--- a/src/lib/fitOutUtils.ts
+++ b/src/lib/fitOutUtils.ts
@@ -1,15 +1,49 @@
-import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
+import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
+ annualBurden: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
+// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n − 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
+export function annuityFactor(rate: number, years: number): number {
+ if (years <= 0) return 0
+ if (rate === 0) return 1 / years
+ const q = Math.pow(1 + rate, years)
+ return (rate * q) / (q - 1)
+}
+
+// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
+const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
+
+/**
+ * Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis —
+ * die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
+ * versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
+ * Ausbau bereits in der Miete enthalten → kein Aufschlag, keine Mieter-Capex.
+ */
+export function effectiveAnnualBurdenPerSqm(p: {
+ fitOut?: string
+ rentPricePerSqm: number
+ mabPerSqm: number
+ fitOutByLandlord?: boolean
+}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
+ if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
+ return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
+ }
+ const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
+ const grossMid = bm ? (bm.min + bm.max) / 2 : 0
+ const net = Math.max(0, grossMid - p.mabPerSqm)
+ const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
+ return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
+}
+
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm),
}
+ const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
+ const annualBurden = {
+ min: Math.round(netTotal.min * factor),
+ max: Math.round(netTotal.max * factor),
+ }
+
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
+ annualBurden,
isFullyCovered,
shortLabel,
detailLabel,
diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts
index 503f0d9..2d13390 100644
--- a/src/mock-data/properties.ts
+++ b/src/mock-data/properties.ts
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
- hardFacts: { fitOut: 'BASIC' },
+ hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx
index 34174f0..b1b305c 100644
--- a/src/pages/demand/MatchDetail.tsx
+++ b/src/pages/demand/MatchDetail.tsx
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
-import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
+import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -220,7 +220,21 @@ export default function MatchDetail() {
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
- tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
+ fitOutByLandlord={property.hardFacts.fitOutByLandlord}
+ />
+ )}
+
+ {/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
+ {!isFuture && property?.hardFacts?.fitOut
+ && !property.hardFacts.fitOutByLandlord
+ && (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
+
)}
diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx
index 0b38f66..e2c2f9c 100644
--- a/src/pages/supply/NewListing.tsx
+++ b/src/pages/supply/NewListing.tsx
@@ -81,6 +81,7 @@ export default function NewListing() {
= {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
}
+import { FIT_OUT_LABELS } from '../../lib/constants'
+
export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
- { value: 'SHELL', label: 'Rohbau' },
- { value: 'BASIC', label: 'Basis-Ausbau' },
- { value: 'FULL', label: 'Vollausbau' },
- { value: 'PREMIUM', label: 'Premiumausbau' },
+ { value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
+ { value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
+ { value: 'FULL', label: FIT_OUT_LABELS.FULL },
+ { value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
]
export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string
floor?: string
fitOut?: string
+ fitOutByLandlord?: boolean
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
diff --git a/src/pages/supply/newListingMapper.ts b/src/pages/supply/newListingMapper.ts
index 71418ec..4db654c 100644
--- a/src/pages/supply/newListingMapper.ts
+++ b/src/pages/supply/newListingMapper.ts
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record
floor: string
fitOut: string
+ fitOutByLandlord?: boolean
parking: string
ceilingHeight: string
images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
- softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
+ softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
@@ -45,7 +46,9 @@ 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,
+ fitOutByLandlord: fitOutByLandlord || undefined,
+ // MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
+ mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 2d20113..9ecc0b9 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -22,5 +22,7 @@ export type {
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
+ FitOutAdvice,
+ FitOutAdviceInput,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'