feat(demand): market rent estimate (asking vs. fair) + pre-market expected price
- lib/rentEstimate: deterministic market estimate from Standort-Intelligence (median rent benchmark + vacancy/trend) → fair rent, verdict BELOW/AT/ABOVE, deltaPct, rationale; suggestFutureRent indexes today's price by city rent trend - MarketPricePanel in MatchDetail: asking vs. fair market rent + verdict badge + rationale; shows pre-market expected price (heute → erwartet) - unit.expectedRentPerSqm: future pre-market price; scorer prefers it over current rent for FUTURE_AVAILABILITY - UnitStructurePanel editor: expected-price field (with indexed suggestion) shown for pre-market-released units - tests: rentEstimate verdict thresholds + future-rent indexing Note: rent estimate is a deterministic market-data calc (not an LLM) — honest & testable; can be routed through IAIService later if a real model is wanted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
|
||||
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
|
||||
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
|
||||
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
city: string
|
||||
assetType: string
|
||||
askingRentPerSqm: number
|
||||
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
|
||||
}
|
||||
|
||||
function chf(v: number): string {
|
||||
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
|
||||
}
|
||||
|
||||
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
|
||||
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
|
||||
if (!est) return null
|
||||
const meta = VERDICT_META[est.verdict]
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</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>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
|
||||
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700 }}>
|
||||
{chf(est.fairRentPerSqm)}
|
||||
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
|
||||
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}>→</Box> {chf(futureRentPerSqm)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
|
||||
{est.rationale}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -14,4 +14,5 @@ export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
|
||||
export { MarketPricePanel } from './MarketPricePanel'
|
||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { suggestFutureRent } from '../../lib/rentEstimate'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
@@ -19,6 +20,7 @@ interface UnitDraft {
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
|
||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||
@@ -74,6 +76,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
fitOutByLandlord: u.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: u.parkingSpots,
|
||||
expectedRentPerSqm: u.expectedRentPerSqm,
|
||||
})
|
||||
setEditingUnit(u.id)
|
||||
}
|
||||
@@ -89,6 +92,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
|
||||
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
|
||||
parkingSpots: unitDraft.parkingSpots,
|
||||
expectedRentPerSqm: unitDraft.expectedRentPerSqm,
|
||||
},
|
||||
})
|
||||
setEditingUnit(null)
|
||||
@@ -412,6 +416,21 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{u.schattenmarktRelease?.enabled && (() => {
|
||||
const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm)
|
||||
return (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<TextField
|
||||
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
|
||||
value={unitDraft.expectedRentPerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
||||
helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
|
||||
{UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||
|
||||
Reference in New Issue
Block a user