feat(matching): annuity-based fit-out cost in score + fix unapplied DQ/confidence modifiers

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-20 18:19:58 +02:00
parent 7a0909e36a
commit e169f8e310
18 changed files with 313 additions and 104 deletions
+11 -24
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index' import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds' import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils' import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils' import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants' import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ────────────────────────────────────────────────────────────── // ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item) const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" /> if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = { const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const monthlyRent = prop.totalRentMonthly const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12) ?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat // ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0 const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate) // Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
let fitOutMonthly = 0 const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
let fitOutMonthlyLabel: string | null = null fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) { })
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0) const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
if (inv && !inv.isFullyCovered) { const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
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 const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0) + (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)} )}
{fitOutMonthlyLabel && ( {fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.) + {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography> </Typography>
)} )}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}> <Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
@@ -213,7 +200,7 @@ export function CompareTableBody({
</Typography> </Typography>
{fitOutLabel && ( {fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}> <Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''} Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -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<string, { label: string; bg: string; border: string; fg: string }> = {
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 (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material' import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react' 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 { 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<string, string> = { const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props { interface Props {
fitOut: string fitOut: string
areaSqm: number areaSqm: number
mabPerSqm: number mabPerSqm: number
rentPricePerSqm: number rentPricePerSqm: number
tenantBudgetPerSqm?: number fitOutByLandlord?: boolean
} }
function chf(value: number): string { function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.` 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 } interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) { 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 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 fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn // Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
? null const hasSurcharge = fitOutPerYear > 0
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm) const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
const fitOutPerYear = investment && !investment.isFullyCovered ? { ? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS), : null
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.`
return ( return (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} /> <HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && ( {hasSurcharge && (
<Chip <Chip
label="CRB/BKP Richtwerte" label="CRB/BKP Richtwerte"
size="small" size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)} value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`} sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/> />
{isReadyToMoveIn ? ( {fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row <Row
label="Ausbau" label="Ausbau"
value="CHF 0." value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`} sub={`${fitOutLabel} — bezugsfertig`}
/> />
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : ( ) : (
<Row <Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`} label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)} value={chf(fitOutPerYear)}
sub={ sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` + `${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') + (mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.` ` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
} }
/> />
)} )}
<Row <Row
label="Total/Jahr" label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)} value={chf(totalPerYear)}
isTotal isTotal
isWarning={isWarning} isWarning={isWarning}
/> />
+1
View File
@@ -13,4 +13,5 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel' export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel' export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel' export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -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' 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 { interface Props {
floor: string floor: string
onFloorChange: (v: string) => void onFloorChange: (v: string) => void
fitOut: string fitOut: string
onFitOutChange: (v: string) => void onFitOutChange: (v: string) => void
fitOutByLandlord: boolean
onFitOutByLandlordChange: (v: boolean) => void
parking: string parking: string
onParkingChange: (v: string) => void onParkingChange: (v: string) => void
ceilingHeight: string ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({ export function TechnicalDetailsSection({
floor, onFloorChange, floor, onFloorChange,
fitOut, onFitOutChange, fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange, parking, onParkingChange,
ceilingHeight, onCeilingHeightChange, ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange, mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange, isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange, minLettableSqm, onMinLettableSqmChange,
}: Props) { }: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return ( return (
<Card sx={{ p: 3, mb: 3 }}> <Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem> <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))} ))}
</TextField> </TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField <TextField
label="Parkplätze" value={parking} label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)} onChange={e => onParkingChange(e.target.value)}
@@ -64,6 +63,40 @@ export function TechnicalDetailsSection({
/> />
</Box> </Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Wer baut aus?</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => 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"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */} {/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel <FormControlLabel
+2
View File
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
floor?: number floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number mieterausbaubeitragPerSqm?: number
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
fitOutByLandlord?: boolean
parking?: number parking?: number
publicTransportScore?: number publicTransportScore?: number
usageType?: string usageType?: string
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
expect(output.excludedReason).toBeTruthy() expect(output.excludedReason).toBeTruthy()
}) })
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
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)', () => { it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed() const need = makeNeed()
const prop = makeProperty() const prop = makeProperty()
+23 -9
View File
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine' import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService' import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer' import { scoreMustHaves } from './mustHaveScorer'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ──────────────────────────────────────────────────────── // ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor { function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0 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 score: number
let explanation: string let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget // Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88 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 { } else {
const overRatio = rent / maxBudget const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) { if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75 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) { } else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45 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 { } else {
score = 20 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) const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0–100 ─────────────────── // ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty // abzgl. severePenalty, clamped 0100 (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))) const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ── // ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore, finalScore,
hardMatchScore, hardMatchScore,
softFactorScore, softFactorScore,
dataQualityModifier: 0, dataQualityModifier,
confidenceModifier: 0, confidenceModifier,
positiveFactors, positiveFactors,
negativeFactors, negativeFactors,
allHardFactors: hardFactors, allHardFactors: hardFactors,
+11 -2
View File
@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService' import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService' import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
export function useParseNeed() { export function useParseNeed() {
return useMutation({ return useMutation({
@@ -25,3 +25,12 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text), mutationFn: (text: string) => parseListingText(text),
}) })
} }
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
return useQuery({
queryKey: ['fitOutAdvice', input],
queryFn: () => aiService.generateFitOutAdvice(input!),
enabled: !!input,
staleTime: Infinity,
})
}
+7 -4
View File
@@ -43,6 +43,7 @@ export interface NewListingFormState {
// Technical // Technical
floor: string floor: string
fitOut: string fitOut: string
fitOutByLandlord: boolean
parking: string parking: string
ceilingHeight: string ceilingHeight: string
mieterausbaubeitrag: string mieterausbaubeitrag: string
@@ -80,6 +81,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void setFloor: (v: string) => void
setFitOut: (v: string) => void setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean) => void
setParking: (v: string) => void setParking: (v: string) => void
setCeilingHeight: (v: string) => void setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +119,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels })) const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '') const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '') const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [fitOutByLandlord, setFitOutByLandlord] = useState(pre.fitOutByLandlord ?? false)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '') const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '') const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '') 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, assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm), areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels, availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm, mieterausbaubeitrag, isFlexible, minLettableSqm,
}), }),
{ {
@@ -185,7 +188,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('') setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('') setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels()) setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('') setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('') setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('') setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false) setAiText(''); setAiApplied(false)
@@ -195,7 +198,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return { return {
assetType, areaSqm, rentPerSqm, availableFrom, description, assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city, street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm, softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone, contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied, images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created, error, created,
@@ -205,7 +208,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription, setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity, setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel, setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm, setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone, setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText, setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage, addImage, removeImage,
+12
View File
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
// Assumed amortization period for fit-out investment — change here to affect all cost calculations // Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5 export const FITOUT_AMORTIZATION_YEARS = 5
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
export const FITOUT_ANNUITY_RATE = 0.05
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
export const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau · Core & Shell',
BASIC: 'Edelrohbau · CAT A',
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
+42 -1
View File
@@ -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 { export interface FitOutInvestment {
grossPerSqm: { min: number; max: number } grossPerSqm: { min: number; max: number }
mabOffset: number mabOffset: number
netPerSqm: { min: number; max: number } netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number } netTotal: { min: number; max: number }
annualBurden: { min: number; max: number }
isFullyCovered: boolean isFullyCovered: boolean
shortLabel: string shortLabel: string
detailLabel: 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 { function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.` if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k` return `CHF ${Math.round(value / 1000)}k`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm), 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 isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm, mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax }, netPerSqm: { min: netMin, max: netMax },
netTotal, netTotal,
annualBurden,
isFullyCovered, isFullyCovered,
shortLabel, shortLabel,
detailLabel, detailLabel,
+1 -1
View File
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8, parkingSpots: 8,
publicTransportMinutes: 5, publicTransportMinutes: 5,
}, },
hardFacts: { fitOut: 'BASIC' }, hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2, floorLevel: 2,
expansionPotentialSqm: 200, expansionPotentialSqm: 200,
contractDurationMonths: 48, contractDurationMonths: 48,
+16 -2
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence' 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 type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData' import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
@@ -220,7 +220,21 @@ export default function MatchDetail() {
areaSqm={property.areaSqm} areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0} mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm} 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') && (
<FitOutAdvicePanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
requiredFitOut={need?.requiredFitOut}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
/> />
)} )}
+1
View File
@@ -81,6 +81,7 @@ export default function NewListing() {
<TechnicalDetailsSection <TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor} floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut} fitOut={form.fitOut} onFitOutChange={form.setFitOut}
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
parking={form.parking} onParkingChange={form.setParking} parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight} ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag} mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+7 -4
View File
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined, LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
} }
import { FIT_OUT_LABELS } from '../../lib/constants'
export const FIT_OUT_OPTIONS = [ export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' }, { value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' }, { value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
{ value: 'BASIC', label: 'Basis-Ausbau' }, { value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
{ value: 'FULL', label: 'Vollausbau' }, { value: 'FULL', label: FIT_OUT_LABELS.FULL },
{ value: 'PREMIUM', label: 'Premiumausbau' }, { value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
] ]
export interface LocationState { export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string propertyId?: string
floor?: string floor?: string
fitOut?: string fitOut?: string
fitOutByLandlord?: boolean
parking?: number parking?: number
ceilingHeight?: number ceilingHeight?: number
mieterausbaubeitragPerSqm?: number mieterausbaubeitragPerSqm?: number
+5 -2
View File
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record<string, string> softLevels: Record<string, string>
floor: string floor: string
fitOut: string fitOut: string
fitOutByLandlord?: boolean
parking: string parking: string
ceilingHeight: string ceilingHeight: string
images: string[] images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const { const {
assetType, street, houseNumber, postalCode, city, assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description, areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl, softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm, mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields } = fields
@@ -45,7 +46,9 @@ export function buildCreatePropertyInput(fields: {
const hf = { const hf = {
floor: floor ? parseInt(floor) : undefined, floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | 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, parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined, ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
} }
+2
View File
@@ -22,5 +22,7 @@ export type {
DataQualityInput, DataQualityInput,
DataQualitySummary, DataQualitySummary,
MarketSignalClassification, MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
} from './ai/IAIService' } from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser' export { parseListingText } from './ai/mock/listingParser'