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 } }}
|
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
{c.requiredFitOut && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
<Box>
|
||||||
<TextField
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||||
size="small" type="number" fullWidth placeholder="z.B. 200"
|
<TextField
|
||||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
|
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>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Divisibility */}
|
{/* 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 { NextActionsPanel } from './NextActionsPanel'
|
||||||
export { FitOutCostPanel } from './FitOutCostPanel'
|
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||||
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
|
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
|
||||||
|
export { MarketPricePanel } from './MarketPricePanel'
|
||||||
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'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
onFloorChange: (v: string) => void
|
onFloorChange: (v: string) => void
|
||||||
fitOut: string
|
fitOut: string
|
||||||
onFitOutChange: (v: string) => void
|
onFitOutChange: (v: string) => void
|
||||||
fitOutByLandlord: boolean
|
fitOutByLandlord: boolean | undefined
|
||||||
onFitOutByLandlordChange: (v: boolean) => void
|
onFitOutByLandlordChange: (v: boolean) => void
|
||||||
parking: string
|
parking: string
|
||||||
onParkingChange: (v: string) => void
|
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) */}
|
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
|
||||||
{showFitOutResponsibility && (
|
{showFitOutResponsibility && (
|
||||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
|
<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' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
exclusive
|
exclusive
|
||||||
size="small"
|
size="small"
|
||||||
value={fitOutByLandlord ? 'landlord' : 'tenant'}
|
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||||
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
|
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
|
||||||
>
|
>
|
||||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</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 }}>
|
<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.
|
Ausbau im Mietzins enthalten — bitte die <strong>bezugsfertige</strong> Miete eintragen.
|
||||||
Es wird kein Aufschlag berechnet.
|
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 { ExternalLink, FileText } from 'lucide-react'
|
||||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||||
import { PropertyMap } from '../shared'
|
import { PropertyMap } from '../shared'
|
||||||
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
|||||||
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
|
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
|
||||||
import { UnitStructurePanel } from './UnitStructurePanel'
|
import { UnitStructurePanel } from './UnitStructurePanel'
|
||||||
import { PreMarketPanel } from './PreMarketPanel'
|
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 {
|
interface PropertyDetailOverviewProps {
|
||||||
p: Property
|
p: Property
|
||||||
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Key metrics */}
|
{/* Key metrics */}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
|
<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: 'Fläche', value: areaLabel },
|
||||||
{ label: 'CHF/m²/Jahr', value: rentLabel },
|
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
|
||||||
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
{ label: 'Verfügbar ab', value: availLabel },
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
@@ -132,54 +159,78 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
{/* Object details */}
|
{/* Object details */}
|
||||||
<SectionTitle title="Objekt & Lage" />
|
<SectionTitle title="Objekt & Lage" />
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<TextField
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||||
select label="Ausbaustandard"
|
<TextField
|
||||||
size="small" fullWidth
|
select label="Ausbaustandard"
|
||||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
size="small" fullWidth
|
||||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
value={editFitOut}
|
||||||
>
|
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
|
||||||
{[
|
>
|
||||||
{ value: '', label: 'Keine Angabe' },
|
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||||
{ value: 'SHELL', label: 'Rohbau' },
|
</TextField>
|
||||||
{ value: 'BASIC', label: 'Basisausbau' },
|
<TextField
|
||||||
{ value: 'FULL', label: 'Vollausbau' },
|
label="Parkplätze"
|
||||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
size="small" fullWidth type="number"
|
||||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
</TextField>
|
value={hf.parking ?? ''}
|
||||||
<TextField
|
onChange={e => patchHF({ parking: parseInt(e.target.value) || undefined })}
|
||||||
label="Mieterausbaubeitrag (CHF/m²)"
|
/>
|
||||||
size="small" fullWidth type="number"
|
<TextField
|
||||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
label="Deckenhöhe (m)"
|
||||||
helperText="Beitrag des Vermieters zum Ausbau"
|
size="small" fullWidth type="number"
|
||||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
value={hf.ceilingHeightM ?? ''}
|
||||||
/>
|
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||||
<TextField
|
/>
|
||||||
label="Parkplätze"
|
</Box>
|
||||||
size="small" fullWidth type="number"
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
{showBuildResponsibility && (
|
||||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
<Box sx={{ mt: 1.5 }}>
|
||||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||||
/>
|
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||||
<TextField
|
</Typography>
|
||||||
label="Deckenhöhe (m)"
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
size="small" fullWidth type="number"
|
<ToggleButtonGroup
|
||||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
exclusive size="small"
|
||||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
value={editByLandlord === undefined ? null : editByLandlord ? 'landlord' : 'tenant'}
|
||||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
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>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<FieldGrid>
|
<FieldGrid>
|
||||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
<Field label={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
|
||||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
{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="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="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||||
<Field label="Importiert aus" value={p.importedFrom} />
|
<Field label="Importiert aus" value={p.importedFrom} />
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
|
|||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
if (!property) return
|
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(
|
updateProperty.mutate(
|
||||||
{ id: property.id, input: draft },
|
{ id: property.id, input: draft },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'
|
||||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
|
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||||
import type { Lease } from '../../domain/lease'
|
import type { Lease } from '../../domain/lease'
|
||||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
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'
|
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 }) {
|
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||||
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
|
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 [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
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)
|
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 freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||||
|
|
||||||
const unitMatches = useUnitMatchesMap(freeUnits, p)
|
const unitMatches = useUnitMatchesMap(freeUnits, p)
|
||||||
@@ -229,6 +275,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
propertyId: p.id,
|
propertyId: p.id,
|
||||||
floor: floorLabel(u),
|
floor: floorLabel(u),
|
||||||
fitOut: p.hardFacts?.fitOut,
|
fitOut: p.hardFacts?.fitOut,
|
||||||
|
fitOutByLandlord: p.hardFacts?.fitOutByLandlord,
|
||||||
parking: p.hardFacts?.parking,
|
parking: p.hardFacts?.parking,
|
||||||
ceilingHeight: p.hardFacts?.ceilingHeightM,
|
ceilingHeight: p.hardFacts?.ceilingHeightM,
|
||||||
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
|
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
|
||||||
@@ -285,9 +332,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</IconButton>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* Area */}
|
{/* Area + Preis + Verfügbarkeit pro Einheit */}
|
||||||
<Box sx={{ textAlign: 'right' }}>
|
<Box sx={{ textAlign: 'right' }}>
|
||||||
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
|
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
|
||||||
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
|
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
|
||||||
@@ -297,9 +354,117 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
ab {u.minLettableSqm} m²
|
ab {u.minLettableSqm} m²
|
||||||
</Typography>
|
</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>
|
||||||
</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 */}
|
{/* Expanded: matches for free units */}
|
||||||
<Collapse in={isExpanded && u.available}>
|
<Collapse in={isExpanded && u.available}>
|
||||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
|
<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
|
areaSqm: number
|
||||||
available: boolean
|
available: boolean
|
||||||
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
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
|
leases?: Lease[] // Mietverträge — current, historical, future
|
||||||
/** @deprecated Use leases[].tenant.companyName */
|
/** @deprecated Use leases[].tenant.companyName */
|
||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
||||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
import { AvailabilityStatus } from '../../domain/enums'
|
import { AvailabilityStatus } from '../../domain/enums'
|
||||||
|
|
||||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
|
|||||||
preMarketUnit: unit,
|
preMarketUnit: unit,
|
||||||
preMarketAllUnits: property?.units,
|
preMarketAllUnits: property?.units,
|
||||||
fitOutLabel: (() => {
|
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
|
if (!fitOut) return undefined
|
||||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
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 (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
|
return LABELS[fitOut] ?? fitOut
|
||||||
})(),
|
})(),
|
||||||
fitOutViable: (() => {
|
fitOutViable: (() => {
|
||||||
const fitOut = property?.hardFacts?.fitOut
|
if (!property) return undefined
|
||||||
|
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||||
if (!fitOut) return undefined
|
if (!fitOut) return undefined
|
||||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
|
||||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
|
||||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
|
||||||
return undefined
|
return undefined
|
||||||
})(),
|
})(),
|
||||||
fitOutInvestment: (() => {
|
fitOutInvestment: (() => {
|
||||||
const fitOut = property?.hardFacts?.fitOut
|
if (!property) return undefined
|
||||||
|
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
const area = unit?.areaSqm ?? property.areaSqm ?? 0
|
||||||
const area = property.areaSqm ?? 0
|
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
|
||||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
|
||||||
})(),
|
})(),
|
||||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||||
minDivisibleUnitSqm: (() => {
|
minDivisibleUnitSqm: (() => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function validate(fields: {
|
|||||||
city: string
|
city: string
|
||||||
areaSqm: string
|
areaSqm: string
|
||||||
rentPerSqm: string
|
rentPerSqm: string
|
||||||
|
fitOut: string
|
||||||
|
fitOutByLandlord: boolean | undefined
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!fields.street.trim()) return 'Strasse erforderlich'
|
if (!fields.street.trim()) return 'Strasse erforderlich'
|
||||||
if (!fields.postalCode.trim()) return 'PLZ 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'
|
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||||
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
||||||
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
|| 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ export interface NewListingFormState {
|
|||||||
// Technical
|
// Technical
|
||||||
floor: string
|
floor: string
|
||||||
fitOut: string
|
fitOut: string
|
||||||
fitOutByLandlord: boolean
|
fitOutByLandlord: boolean | undefined
|
||||||
parking: string
|
parking: string
|
||||||
ceilingHeight: string
|
ceilingHeight: string
|
||||||
mieterausbaubeitrag: string
|
mieterausbaubeitrag: string
|
||||||
@@ -81,7 +86,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
|
setFitOutByLandlord: (v: boolean | undefined) => 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
|
||||||
@@ -119,7 +124,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 [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
|
||||||
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) : '')
|
||||||
@@ -164,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
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 }
|
if (err) { setError(err); return }
|
||||||
setError(null)
|
setError(null)
|
||||||
createProperty.mutate(
|
createProperty.mutate(
|
||||||
@@ -188,7 +193,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(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
|
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
|
||||||
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||||
setAiText(''); setAiApplied(false)
|
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 { 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, FitOutAdvicePanel } from '../../components/match-detail'
|
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } 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'
|
||||||
@@ -39,6 +39,7 @@ export default function MatchDetail() {
|
|||||||
const { openInquiryDialog } = useInquiryStore()
|
const { openInquiryDialog } = useInquiryStore()
|
||||||
|
|
||||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||||
|
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 ── */}
|
{/* ── Full analysis toggle + expanded panels ── */}
|
||||||
<MatchDetailScoreBreakdown
|
<MatchDetailScoreBreakdown
|
||||||
match={match}
|
match={match}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ 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,
|
||||||
fitOutByLandlord: fitOutByLandlord || undefined,
|
fitOutByLandlord,
|
||||||
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
|
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
|
||||||
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||||
parking: parking ? parseInt(parking) : undefined,
|
parking: parking ? parseInt(parking) : undefined,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
|
|||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import { getEffectiveUnits } from '../domain/property'
|
import { getEffectiveUnits } from '../domain/property'
|
||||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||||
|
import { resolveUnitFacts } from '../lib/unitFacts'
|
||||||
|
|
||||||
function strengthFromScore(s: number): MatchStrength {
|
function strengthFromScore(s: number): MatchStrength {
|
||||||
if (s >= 75) return MatchStrength.STRONG
|
if (s >= 75) return MatchStrength.STRONG
|
||||||
@@ -71,13 +72,16 @@ function scoreProperty(
|
|||||||
overrideArea?: number,
|
overrideArea?: number,
|
||||||
overridePrice?: number,
|
overridePrice?: number,
|
||||||
overrideResultType?: string,
|
overrideResultType?: string,
|
||||||
|
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
|
||||||
): MatchEngineOutput {
|
): MatchEngineOutput {
|
||||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
|
||||||
return calculateScore(need, {
|
return calculateScore(need, {
|
||||||
...prop,
|
...prop,
|
||||||
areaSqm: overrideArea ?? prop.areaSqm,
|
areaSqm: overrideArea ?? prop.areaSqm,
|
||||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
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)
|
return calculateScore(need, prop)
|
||||||
@@ -101,7 +105,15 @@ export function generateMatchesForNeed(need: Need): void {
|
|||||||
}
|
}
|
||||||
for (const unit of prop.units!) {
|
for (const unit of prop.units!) {
|
||||||
if (!unit.schattenmarktRelease?.enabled) continue
|
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
|
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||||
|
|||||||
Reference in New Issue
Block a user