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 { 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')} m²`}
|
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
|
<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')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Row
|
<Row
|
||||||
label="Total/Jahr"
|
label="Total/Jahr"
|
||||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
value={chf(totalPerYear)}
|
||||||
isTotal
|
isTotal
|
||||||
isWarning={isWarning}
|
isWarning={isWarning}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 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)))
|
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
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user