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:
@@ -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 <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
||||
|
||||
const FIT_OUT_LABELS: Record<string, string> = {
|
||||
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 && (
|
||||
<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 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>
|
||||
{fitOutLabel && (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<HardHat size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
|
||||
{!isReadyToMoveIn && (
|
||||
{hasSurcharge && (
|
||||
<Chip
|
||||
label="CRB/BKP Richtwerte"
|
||||
size="small"
|
||||
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
|
||||
value={chf(rentPerYear)}
|
||||
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`}
|
||||
/>
|
||||
{isReadyToMoveIn ? (
|
||||
{fitOutByLandlord ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
|
||||
/>
|
||||
) : !hasSurcharge ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — bezugsfertig`}
|
||||
/>
|
||||
) : investment?.isFullyCovered ? (
|
||||
<Row
|
||||
label="Ausbau (amort.)"
|
||||
value="CHF 0.–"
|
||||
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
|
||||
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
|
||||
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
|
||||
value={chf(fitOutPerYear)}
|
||||
sub={
|
||||
`CHF ${investment?.grossPerSqm.min}–${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
|
||||
`${fitOutLabel}` +
|
||||
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
|
||||
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
||||
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Total/Jahr"
|
||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
||||
value={chf(totalPerYear)}
|
||||
isTotal
|
||||
isWarning={isWarning}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<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>
|
||||
))}
|
||||
</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
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
@@ -64,6 +63,40 @@ export function TechnicalDetailsSection({
|
||||
/>
|
||||
</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 */}
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
|
||||
floor?: number
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
|
||||
fitOutByLandlord?: boolean
|
||||
parking?: number
|
||||
publicTransportScore?: number
|
||||
usageType?: string
|
||||
|
||||
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
|
||||
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)', () => {
|
||||
const need = makeNeed()
|
||||
const prop = makeProperty()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-2
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>(() => ({ ...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,
|
||||
|
||||
@@ -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
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') && (
|
||||
<FitOutAdvicePanel
|
||||
fitOut={property.hardFacts.fitOut}
|
||||
areaSqm={property.areaSqm}
|
||||
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
|
||||
rentPricePerSqm={property.rentPricePerSqm}
|
||||
requiredFitOut={need?.requiredFitOut}
|
||||
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function NewListing() {
|
||||
<TechnicalDetailsSection
|
||||
floor={form.floor} onFloorChange={form.setFloor}
|
||||
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
|
||||
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
|
||||
parking={form.parking} onParkingChange={form.setParking}
|
||||
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
|
||||
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
|
||||
|
||||
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
softLevels: Record<string, string>
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -22,5 +22,7 @@ export type {
|
||||
DataQualityInput,
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
FitOutAdvice,
|
||||
FitOutAdviceInput,
|
||||
} from './ai/IAIService'
|
||||
export { parseListingText } from './ai/mock/listingParser'
|
||||
|
||||
Reference in New Issue
Block a user