Compare commits
4 Commits
e169f8e310
...
fca113e7ab
| Author | SHA1 | Date | |
|---|---|---|---|
| fca113e7ab | |||
| fd6ed105bb | |||
| 647493bf3c | |||
| 5d26dd4a6c |
@@ -73,16 +73,18 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
|
||||
/>
|
||||
</Box>
|
||||
{c.requiredFitOut && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Divisibility */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
onFloorChange: (v: string) => void
|
||||
fitOut: string
|
||||
onFitOutChange: (v: string) => void
|
||||
fitOutByLandlord: boolean
|
||||
fitOutByLandlord: boolean | undefined
|
||||
onFitOutByLandlordChange: (v: boolean) => void
|
||||
parking: string
|
||||
onParkingChange: (v: string) => void
|
||||
@@ -66,19 +66,25 @@ export function TechnicalDetailsSection({
|
||||
{/* 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>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
value={fitOutByLandlord === undefined ? null : 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 ? (
|
||||
{fitOutByLandlord === undefined ? (
|
||||
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
|
||||
Pflichtangabe bei Rohbau/Edelrohbau — bitte wählen, wer den Ausbau trägt.
|
||||
</Typography>
|
||||
) : 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
|
||||
import { UnitStructurePanel } from './UnitStructurePanel'
|
||||
import { PreMarketPanel } from './PreMarketPanel'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
|
||||
const FIT_OUT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface PropertyDetailOverviewProps {
|
||||
p: Property
|
||||
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
|
||||
}
|
||||
|
||||
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
|
||||
const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
// Mehr-Einheiten-Objekt: Preis als Spanne, Fläche mit Anzahl, Verfügbarkeit pro Einheit
|
||||
const units = p.units ?? []
|
||||
const isMultiUnit = units.length > 1
|
||||
const unitPrices = units.map(u => u.rentPricePerSqm ?? p.rentPricePerSqm).filter(v => v > 0)
|
||||
const priceMin = unitPrices.length ? Math.min(...unitPrices) : p.rentPricePerSqm
|
||||
const priceMax = unitPrices.length ? Math.max(...unitPrices) : p.rentPricePerSqm
|
||||
const rentLabel = isMultiUnit && priceMin !== priceMax
|
||||
? `CHF ${priceMin.toLocaleString('de-CH')}–${priceMax.toLocaleString('de-CH')}`
|
||||
: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
const areaLabel = isMultiUnit
|
||||
? `${p.areaSqm.toLocaleString('de-CH')} m² · ${units.length} Einheiten`
|
||||
: `${p.areaSqm.toLocaleString('de-CH')} m²`
|
||||
const availLabel = isMultiUnit
|
||||
? 'pro Einheit ↓'
|
||||
: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined
|
||||
|
||||
// Effektive (Draft-überlagerte) hardFacts für den Bearbeiten-Modus
|
||||
const hf = draft.hardFacts ?? p.hardFacts ?? {}
|
||||
const editFitOut = hf.fitOut ?? ''
|
||||
const editByLandlord = hf.fitOutByLandlord
|
||||
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
|
||||
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
|
||||
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Key metrics */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
|
||||
{[
|
||||
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||
{ label: 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
||||
{ label: 'Fläche', value: areaLabel },
|
||||
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: availLabel },
|
||||
].map(({ label, value }) => (
|
||||
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
@@ -132,54 +159,78 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
/>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={editFitOut}
|
||||
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
|
||||
>
|
||||
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={hf.parking ?? ''}
|
||||
onChange={e => patchHF({ parking: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={hf.ceilingHeightM ?? ''}
|
||||
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showBuildResponsibility && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={editByLandlord === undefined ? null : editByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{editByLandlord === undefined && (
|
||||
<Typography variant="caption" sx={{ color: '#b45309' }}>
|
||||
Pflichtangabe bei Rohbau/Edelrohbau
|
||||
</Typography>
|
||||
)}
|
||||
{editByLandlord === false && (
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" type="number" sx={{ width: 240 }}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters — optional"
|
||||
value={hf.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
|
||||
{p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && (
|
||||
<Field label="Ausbau-Träger" value={p.hardFacts.fitOutByLandlord ? 'Vermieter (im Mietzins)' : 'Mieter'} />
|
||||
)}
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label={isMultiUnit ? 'Parkplätze (Objekt-Pool)' : 'Parkplätze'} value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
<Field label="Importiert aus" value={p.importedFrom} />
|
||||
|
||||
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
|
||||
|
||||
function saveEdit() {
|
||||
if (!property) return
|
||||
// Träger-Pflicht bei Rohbau/Edelrohbau (SHELL/BASIC)
|
||||
const effFitOut = draft.hardFacts?.fitOut ?? property.hardFacts?.fitOut
|
||||
const effByLandlord = draft.hardFacts?.fitOutByLandlord ?? property.hardFacts?.fitOutByLandlord
|
||||
if ((effFitOut === 'SHELL' || effFitOut === 'BASIC') && effByLandlord === undefined) {
|
||||
showToast('Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter).', 'error')
|
||||
return
|
||||
}
|
||||
updateProperty.mutate(
|
||||
{ id: property.id, input: draft },
|
||||
{
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import type { Lease } from '../../domain/lease'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
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'])
|
||||
|
||||
interface UnitDraft {
|
||||
rentPricePerSqm?: number
|
||||
availableFrom?: string
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
|
||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
|
||||
@@ -50,8 +64,40 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||
const [unitDraft, setUnitDraft] = useState<UnitDraft>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
function openUnitEditor(u: PropertyUnit) {
|
||||
setUnitDraft({
|
||||
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||
availableFrom: u.availableFrom,
|
||||
fitOut: u.fitOut ?? '',
|
||||
fitOutByLandlord: u.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: u.parkingSpots,
|
||||
expectedRentPerSqm: u.expectedRentPerSqm,
|
||||
})
|
||||
setEditingUnit(u.id)
|
||||
}
|
||||
function saveUnitEditor(unitId: string) {
|
||||
const effFit = (unitDraft.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||
const needsBuild = effFit === 'SHELL' || effFit === 'BASIC'
|
||||
updateUnit.mutate({
|
||||
unitId,
|
||||
data: {
|
||||
rentPricePerSqm: unitDraft.rentPricePerSqm,
|
||||
availableFrom: unitDraft.availableFrom || undefined,
|
||||
fitOut: effFit,
|
||||
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
|
||||
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
|
||||
parkingSpots: unitDraft.parkingSpots,
|
||||
expectedRentPerSqm: unitDraft.expectedRentPerSqm,
|
||||
},
|
||||
})
|
||||
setEditingUnit(null)
|
||||
}
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
const unitMatches = useUnitMatchesMap(freeUnits, p)
|
||||
@@ -229,6 +275,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
propertyId: p.id,
|
||||
floor: floorLabel(u),
|
||||
fitOut: p.hardFacts?.fitOut,
|
||||
fitOutByLandlord: p.hardFacts?.fitOutByLandlord,
|
||||
parking: p.hardFacts?.parking,
|
||||
ceilingHeight: p.hardFacts?.ceilingHeightM,
|
||||
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
|
||||
@@ -285,9 +332,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Edit price / availability per unit */}
|
||||
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => (editingUnit === u.id ? setEditingUnit(null) : openUnitEditor(u))}
|
||||
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Area */}
|
||||
{/* Area + Preis + Verfügbarkeit pro Einheit */}
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
|
||||
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
|
||||
@@ -297,9 +354,117 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
ab {u.minLettableSqm} m²
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
|
||||
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
|
||||
</Typography>
|
||||
{u.parkingSpots != null && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||
{u.parkingSpots} PP
|
||||
</Typography>
|
||||
)}
|
||||
{u.available && (() => {
|
||||
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||
{av ? `ab ${new Date(av).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` : 'sofort'}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Inline editor: price + availability per unit */}
|
||||
<Collapse in={editingUnit === u.id}>
|
||||
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: u.available ? '1fr 1fr' : '1fr', gap: 1.25 }}>
|
||||
<TextField
|
||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||
value={unitDraft.rentPricePerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
||||
helperText="Leer = Objekt-Preis"
|
||||
/>
|
||||
{u.available && (
|
||||
<TextField
|
||||
label="Verfügbar ab" type="date" size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={unitDraft.availableFrom ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, availableFrom: e.target.value }))}
|
||||
helperText="Leer = sofort"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25, mt: 1.25 }}>
|
||||
<TextField
|
||||
select size="small" label="Ausbaustandard"
|
||||
value={unitDraft.fitOut ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, fitOut: e.target.value }))}
|
||||
>
|
||||
<MenuItem value="">Wie Objekt{p.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut})` : ''}</MenuItem>
|
||||
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze (Zuteilung)" type="number" size="small"
|
||||
value={unitDraft.parkingSpots ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
helperText={p.hardFacts?.parking != null ? `aus Objekt-Pool: ${p.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||
/>
|
||||
</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>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={unitDraft.fitOutByLandlord === undefined ? null : unitDraft.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) setUnitDraft(d => ({ ...d, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{unitDraft.fitOutByLandlord === false && (
|
||||
<TextField
|
||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
|
||||
value={unitDraft.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.25, justifyContent: 'flex-end' }}>
|
||||
<Button size="small" onClick={() => setEditingUnit(null)} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button size="small" variant="contained" onClick={() => saveUnitEditor(u.id)} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* Expanded: matches for free units */}
|
||||
<Collapse in={isExpanded && u.available}>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface PropertyUnit {
|
||||
areaSqm: number
|
||||
available: boolean
|
||||
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
||||
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
|
||||
// Unit-level overrides — fall back to property.hardFacts.* when unset.
|
||||
// Relevant where units of one property are matched individually (pre-market, multi-unit).
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
|
||||
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
|
||||
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
|
||||
expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete
|
||||
leases?: Lease[] // Mietverträge — current, historical, future
|
||||
/** @deprecated Use leases[].tenant.companyName */
|
||||
currentTenant?: string
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { AvailabilityStatus } from '../../domain/enums'
|
||||
|
||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
|
||||
preMarketUnit: unit,
|
||||
preMarketAllUnits: property?.units,
|
||||
fitOutLabel: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut) return undefined
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
||||
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
|
||||
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
|
||||
return LABELS[fitOut] ?? fitOut
|
||||
})(),
|
||||
fitOutViable: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut) return undefined
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
||||
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
|
||||
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
|
||||
return undefined
|
||||
})(),
|
||||
fitOutInvestment: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const area = property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
||||
const area = unit?.areaSqm ?? property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
|
||||
})(),
|
||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||
minDivisibleUnitSqm: (() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ function validate(fields: {
|
||||
city: string
|
||||
areaSqm: string
|
||||
rentPerSqm: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean | undefined
|
||||
}): string | null {
|
||||
if (!fields.street.trim()) return 'Strasse erforderlich'
|
||||
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
|
||||
@@ -23,6 +25,9 @@ function validate(fields: {
|
||||
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
||||
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
|
||||
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
|
||||
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -43,7 +48,7 @@ export interface NewListingFormState {
|
||||
// Technical
|
||||
floor: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean
|
||||
fitOutByLandlord: boolean | undefined
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
mieterausbaubeitrag: string
|
||||
@@ -81,7 +86,7 @@ export interface NewListingFormHandlers {
|
||||
setSoftLevel: (key: string, value: string) => void
|
||||
setFloor: (v: string) => void
|
||||
setFitOut: (v: string) => void
|
||||
setFitOutByLandlord: (v: boolean) => void
|
||||
setFitOutByLandlord: (v: boolean | undefined) => void
|
||||
setParking: (v: string) => void
|
||||
setCeilingHeight: (v: string) => void
|
||||
setMieterausbaubeitrag: (v: string) => void
|
||||
@@ -119,7 +124,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 [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
|
||||
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) : '')
|
||||
@@ -164,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
|
||||
if (err) { setError(err); return }
|
||||
setError(null)
|
||||
createProperty.mutate(
|
||||
@@ -188,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
|
||||
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
|
||||
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
||||
|
||||
describe('estimateMarketRent', () => {
|
||||
it('flags asking rent clearly above the city median as ABOVE', () => {
|
||||
// Zürich OFFICE median = 42
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 60)
|
||||
expect(est).not.toBeNull()
|
||||
expect(est!.verdict).toBe('ABOVE')
|
||||
expect(est!.deltaPct).toBeGreaterThan(5)
|
||||
expect(est!.fairRentPerSqm).toBe(42)
|
||||
})
|
||||
|
||||
it('flags asking rent clearly below median as BELOW', () => {
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 30)
|
||||
expect(est!.verdict).toBe('BELOW')
|
||||
expect(est!.deltaPct).toBeLessThan(-5)
|
||||
})
|
||||
|
||||
it('treats near-median asking rent as AT (within ±5%)', () => {
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 43)
|
||||
expect(est!.verdict).toBe('AT')
|
||||
})
|
||||
|
||||
it('returns null for unknown city', () => {
|
||||
expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull()
|
||||
})
|
||||
|
||||
it('suggestFutureRent indexes by the city rent trend', () => {
|
||||
// Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded)
|
||||
expect(suggestFutureRent('Zürich', 100)).toBe(104)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getMarketRent, getCityIntelligence } from './locationIntelligence'
|
||||
|
||||
export type RentVerdict = 'BELOW' | 'AT' | 'ABOVE'
|
||||
|
||||
export interface RentEstimate {
|
||||
fairRentPerSqm: number // Median-Marktmiete als faire Benchmark
|
||||
askingRentPerSqm: number
|
||||
verdict: RentVerdict // Angebot vs. Markt
|
||||
deltaPct: number // +über / −unter Markt (gerundet)
|
||||
rationale: string
|
||||
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
}
|
||||
|
||||
/**
|
||||
* Markt-Einschätzung der Angebotsmiete: Vergleich gegen die Median-Marktmiete
|
||||
* (aus Standort-Intelligence) plus Leerstand/Miettrend als Kontext. Deterministisch.
|
||||
*/
|
||||
export function estimateMarketRent(city: string, assetType: string, askingRentPerSqm: number): RentEstimate | null {
|
||||
const market = getMarketRent(city, assetType)
|
||||
const intel = getCityIntelligence(city)
|
||||
if (market == null || !intel || askingRentPerSqm <= 0) return null
|
||||
|
||||
const fair = market
|
||||
const deltaPct = Math.round(((askingRentPerSqm - fair) / fair) * 100)
|
||||
const verdict: RentVerdict = deltaPct > 5 ? 'ABOVE' : deltaPct < -5 ? 'BELOW' : 'AT'
|
||||
const trendNote = `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`
|
||||
const verdictText =
|
||||
verdict === 'ABOVE' ? `Angebot ${deltaPct}% über Marktmedian.`
|
||||
: verdict === 'BELOW' ? `Angebot ${Math.abs(deltaPct)}% unter Marktmedian.`
|
||||
: 'Angebot marktkonform.'
|
||||
const rationale = `Median ${city}: CHF ${fair}/m² · Leerstand ${intel.vacancyRatePct}% · ${trendNote}. ${verdictText}`
|
||||
const confidence = intel.demandStrength === 'LOW' ? 'LOW' : intel.avgDaysOnMarket > 70 ? 'MEDIUM' : 'HIGH'
|
||||
|
||||
return { fairRentPerSqm: fair, askingRentPerSqm, verdict, deltaPct, rationale, confidence }
|
||||
}
|
||||
|
||||
/** Indexierter Vorschlag für den künftigen Preis (Pre-Market): Heutepreis × (1 + Miettrend). */
|
||||
export function suggestFutureRent(city: string, currentRentPerSqm: number): number | null {
|
||||
const intel = getCityIntelligence(city)
|
||||
if (!intel || currentRentPerSqm <= 0) return null
|
||||
return Math.round(currentRentPerSqm * (1 + intel.rentTrend12m / 100))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Property, PropertyUnit } from '../domain/property'
|
||||
|
||||
export interface ResolvedUnitFacts {
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
mabPerSqm: number
|
||||
fitOutByLandlord?: boolean
|
||||
parkingSpots?: number
|
||||
rentPricePerSqm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert.
|
||||
* Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback.
|
||||
*/
|
||||
export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts {
|
||||
const hf = property.hardFacts
|
||||
const singleUnit = (property.units?.length ?? 0) <= 1
|
||||
return {
|
||||
fitOut: unit?.fitOut ?? hf?.fitOut,
|
||||
mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0,
|
||||
fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord,
|
||||
parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined),
|
||||
rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm,
|
||||
}
|
||||
}
|
||||
@@ -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, FitOutAdvicePanel } from '../../components/match-detail'
|
||||
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
@@ -39,6 +39,7 @@ export default function MatchDetail() {
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -238,6 +239,16 @@ export default function MatchDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */}
|
||||
{property?.location?.city && property.rentPricePerSqm > 0 && (
|
||||
<MarketPricePanel
|
||||
city={property.location.city}
|
||||
assetType={property.assetType}
|
||||
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
|
||||
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Full analysis toggle + expanded panels ── */}
|
||||
<MatchDetailScoreBreakdown
|
||||
match={match}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
const hf = {
|
||||
floor: floor ? parseInt(floor) : undefined,
|
||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
||||
fitOutByLandlord: fitOutByLandlord || undefined,
|
||||
fitOutByLandlord,
|
||||
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
|
||||
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||
parking: parking ? parseInt(parking) : undefined,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
import { resolveUnitFacts } from '../lib/unitFacts'
|
||||
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
@@ -71,13 +72,16 @@ function scoreProperty(
|
||||
overrideArea?: number,
|
||||
overridePrice?: number,
|
||||
overrideResultType?: string,
|
||||
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
|
||||
): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
// Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts)
|
||||
hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
@@ -101,7 +105,15 @@ export function generateMatchesForNeed(need: Need): void {
|
||||
}
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
const facts = resolveUnitFacts(prop, unit)
|
||||
// Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete
|
||||
const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, {
|
||||
fitOut: facts.fitOut,
|
||||
mieterausbaubeitragPerSqm: facts.mabPerSqm,
|
||||
fitOutByLandlord: facts.fitOutByLandlord,
|
||||
parking: facts.parkingSpots,
|
||||
})
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
|
||||
Reference in New Issue
Block a user