From e169f8e3101555703d2d9c2a020ac9da456cbac8 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 20 Jun 2026 18:19:58 +0200 Subject: [PATCH] feat(matching): annuity-based fit-out cost in score + fix unapplied DQ/confidence modifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mieterausbau / fit-out economics — surface true total cost of occupancy: - Annuity calc (annuityFactor + effectiveAnnualBurdenPerSqm) replaces straight-line ÷5; FITOUT_ANNUITY_RATE=5% - New hardFacts.fitOutByLandlord: "Wer baut aus?" toggle in NewListing — landlord-borne fit-out is priced into rent (no surcharge), tenant-borne SHELL/BASIC adds annuitized cost minus MAB - scoreBudget now compares effective annual burden (rent + fit-out annuity) vs budget instead of cold rent only; FULL/PREMIUM and landlord-borne unchanged - FitOutCostPanel + CompareTableBody compute annuitized, tenant-aware burden - Central FIT_OUT_LABELS with industry/international vocabulary (Rohbau·Core&Shell, Edelrohbau·CAT A, etc.) - Activate existing generateFitOutAdvice via useFitOutAdvice hook + new FitOutAdvicePanel (MIETERAUSBAU/BKZ/MAB-Amortisation + negotiation tip), shown for tenant-borne SHELL/BASIC - MAB field only asked when tenant builds out (optional) — one new toggle, no extra data burden for property managers Fix: DQ/confidence modifiers were computed but never applied to finalScore (hardcoded 0 in output) — now folded into rawFinal and exposed. Trust-first: weak data quality lowers the score. Resolves 3 pre-existing red tests. Co-Authored-By: Claude Sonnet 4.6 --- src/components/compare/CompareTableBody.tsx | 35 +++----- .../match-detail/FitOutAdvicePanel.tsx | 82 +++++++++++++++++++ .../match-detail/FitOutCostPanel.tsx | 73 ++++++----------- src/components/match-detail/index.ts | 1 + .../new-listing/TechnicalDetailsSection.tsx | 51 ++++++++++-- src/domain/property.ts | 2 + .../__tests__/scoreCalculator.test.ts | 21 +++++ src/features/matching/scoreCalculator.ts | 32 ++++++-- src/hooks/useAI.ts | 13 ++- src/hooks/useNewListingForm.ts | 11 ++- src/lib/constants.ts | 12 +++ src/lib/fitOutUtils.ts | 43 +++++++++- src/mock-data/properties.ts | 2 +- src/pages/demand/MatchDetail.tsx | 18 +++- src/pages/supply/NewListing.tsx | 1 + src/pages/supply/newListingConstants.ts | 11 ++- src/pages/supply/newListingMapper.ts | 7 +- src/services/aiService.ts | 2 + 18 files changed, 313 insertions(+), 104 deletions(-) create mode 100644 src/components/match-detail/FitOutAdvicePanel.tsx 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({ {o.label} ))} - 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'