Compare commits
5 Commits
74f9660581
...
8cb6f21581
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cb6f21581 | |||
| 29be4f6e54 | |||
| 09095a1a32 | |||
| fb029cf0bc | |||
| 3e1e945661 |
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import type { Property, PropertyUnit } from '../../domain/property'
|
||||||
|
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
|
||||||
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
|
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
|
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||||
|
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
|
||||||
|
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
|
||||||
|
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
|
||||||
|
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
fitOut?: string
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
mieterausbaubeitragPerSqm?: number
|
||||||
|
parkingSpots?: number
|
||||||
|
expectedRentPerSqm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
property: Property
|
||||||
|
unit: PropertyUnit
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
|
||||||
|
export function PreMarketPriceAdvisor({ property, unit }: Props) {
|
||||||
|
const updateUnit = useUpdateUnit(property.id)
|
||||||
|
const [d, setD] = useState<Draft>(() => ({
|
||||||
|
fitOut: unit.fitOut ?? '',
|
||||||
|
fitOutByLandlord: unit.fitOutByLandlord,
|
||||||
|
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||||
|
parkingSpots: unit.parkingSpots,
|
||||||
|
expectedRentPerSqm: unit.expectedRentPerSqm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const facts = resolveUnitFacts(property, unit)
|
||||||
|
const current = facts.rentPricePerSqm
|
||||||
|
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||||
|
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = usePreMarketRentRecommendation({
|
||||||
|
city: property.location?.city ?? '',
|
||||||
|
assetType: property.assetType,
|
||||||
|
areaSqm: unit.areaSqm,
|
||||||
|
currentRentPerSqm: current,
|
||||||
|
availableFrom: unit.schattenmarktRelease?.availableFrom,
|
||||||
|
})
|
||||||
|
const rec = data?.data
|
||||||
|
const verdict = rec ? VERDICT_META[rec.verdict] : null
|
||||||
|
|
||||||
|
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 0.75, p: 1.25, borderRadius: 1, bgcolor: 'white', border: `1px solid ${DS_SURFACE.purple.border}` }}>
|
||||||
|
{/* 1) Ausbau & Ausstattung */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
select size="small" label="Ausbaustandard"
|
||||||
|
value={d.fitOut ?? ''}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value
|
||||||
|
const nb = v === 'SHELL' || v === 'BASIC'
|
||||||
|
setD(p => ({ ...p, fitOut: v }))
|
||||||
|
save({ fitOut: (v || undefined) as PropertyUnit['fitOut'], ...(nb ? {} : { fitOutByLandlord: undefined, mieterausbaubeitragPerSqm: undefined }) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.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" type="number" size="small"
|
||||||
|
value={d.parkingSpots ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ parkingSpots: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
helperText={property.hardFacts?.parking != null ? `Pool: ${property.hardFacts.parking}` : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{needsBuild && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive size="small"
|
||||||
|
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
if (!v) return
|
||||||
|
const landlord = v === 'landlord'
|
||||||
|
setD(p => ({ ...p, fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) }))
|
||||||
|
save({ fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||||
|
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
{d.fitOutByLandlord === false && (
|
||||||
|
<TextField
|
||||||
|
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 140 }}
|
||||||
|
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.25 }} />
|
||||||
|
|
||||||
|
{/* 2) KI-Preisempfehlung */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
|
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
|
||||||
|
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<CircularProgress size={12} sx={{ color: DS_PRE_MARKET.accent }} />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9' }}>Marktanalyse läuft…</Typography>
|
||||||
|
</Box>
|
||||||
|
) : isError || !rec ? (
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
|
||||||
|
Empfehlung: CHF {rec.recommendedPerSqm}/m²
|
||||||
|
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm})</Box>
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.25 }}>
|
||||||
|
Heute CHF {current}/m²
|
||||||
|
<Box component="span" sx={{ fontWeight: 700, ml: 0.5 }}>
|
||||||
|
{rec.deltaVsCurrentPct > 0 ? `+${rec.deltaVsCurrentPct}%` : `${rec.deltaVsCurrentPct}%`} vs. heute
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', color: '#7c3aed', mb: 0.625 }}>
|
||||||
|
{rec.drivers.join(' · ')}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<TextField
|
||||||
|
type="number" size="small" label="Pre-Market-Preis"
|
||||||
|
placeholder={`z.B. ${rec.recommendedPerSqm}`}
|
||||||
|
value={d.expectedRentPerSqm ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
|
||||||
|
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
|
||||||
|
>
|
||||||
|
Empfehlung übernehmen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { FIT_OUT_LABELS } from '../../lib/constants'
|
|||||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
import { floorLabel } from './PropertyDetailHelpers'
|
import { floorLabel } from './PropertyDetailHelpers'
|
||||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||||
|
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
|
||||||
|
|
||||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||||
|
|
||||||
@@ -114,14 +115,16 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit + enabled toggle */}
|
{/* Edit (nur vor Freigabe — danach inline) + enabled toggle */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
||||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||||
|
{!us.enabled && (
|
||||||
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
||||||
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
|
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={us.enabled}
|
checked={us.enabled}
|
||||||
@@ -138,17 +141,21 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Always-visible per-unit details */}
|
{/* Vor Freigabe: Lese-Details + Stift-Editor. Nach Freigabe: volle Inline-Konfiguration. */}
|
||||||
|
{us.enabled ? (
|
||||||
|
<PreMarketPriceAdvisor property={property} unit={u} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
||||||
{detailParts.join(' · ')}
|
{detailParts.join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Inline editor (shared) */}
|
|
||||||
<Collapse in={editing === u.id}>
|
<Collapse in={editing === u.id}>
|
||||||
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
||||||
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
||||||
</Box>
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typo
|
|||||||
import type { Property, PropertyUnit } from '../../domain/property'
|
import type { Property, PropertyUnit } from '../../domain/property'
|
||||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
import { suggestFutureRent } from '../../lib/rentEstimate'
|
|
||||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||||
@@ -15,7 +14,6 @@ interface UnitDraft {
|
|||||||
fitOutByLandlord?: boolean
|
fitOutByLandlord?: boolean
|
||||||
mieterausbaubeitragPerSqm?: number
|
mieterausbaubeitragPerSqm?: number
|
||||||
parkingSpots?: number
|
parkingSpots?: number
|
||||||
expectedRentPerSqm?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,7 +22,7 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared per-unit editor: price, availability, fit-out, cost-bearer, MAB, parking allocation, expected price. */
|
/** Shared per-unit editor: conditions (price, availability), fit-out group, parking allocation. */
|
||||||
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||||
const updateUnit = useUpdateUnit(property.id)
|
const updateUnit = useUpdateUnit(property.id)
|
||||||
const [d, setD] = useState<UnitDraft>(() => ({
|
const [d, setD] = useState<UnitDraft>(() => ({
|
||||||
@@ -34,12 +32,10 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
fitOutByLandlord: unit.fitOutByLandlord,
|
fitOutByLandlord: unit.fitOutByLandlord,
|
||||||
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||||
parkingSpots: unit.parkingSpots,
|
parkingSpots: unit.parkingSpots,
|
||||||
expectedRentPerSqm: unit.expectedRentPerSqm,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const effFit = d.fitOut || property.hardFacts?.fitOut
|
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||||
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||||
const suggestion = suggestFutureRent(property.location?.city ?? '', d.rentPricePerSqm ?? unit.rentPricePerSqm ?? property.rentPricePerSqm)
|
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||||
@@ -53,15 +49,15 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
|
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
|
||||||
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
||||||
parkingSpots: d.parkingSpots,
|
parkingSpots: d.parkingSpots,
|
||||||
expectedRentPerSqm: d.expectedRentPerSqm,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25 }}>
|
{/* Konditionen */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||||
value={d.rentPricePerSqm ?? ''}
|
value={d.rentPricePerSqm ?? ''}
|
||||||
@@ -79,7 +75,21 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
select size="small" label="Ausbaustandard"
|
label="Parkplätze (Zuteilung)" type="number" size="small"
|
||||||
|
value={d.parkingSpots ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
helperText={property.hardFacts?.parking != null ? `Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Ausbau — Standard + Träger + MAB als Gruppe */}
|
||||||
|
<Box sx={{ mt: 1.5, p: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, bgcolor: 'white' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 1 }}>
|
||||||
|
Ausbau
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
select size="small" label="Ausbaustandard" fullWidth
|
||||||
value={d.fitOut ?? ''}
|
value={d.fitOut ?? ''}
|
||||||
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
||||||
>
|
>
|
||||||
@@ -89,24 +99,6 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
|
||||||
label="Parkplätze (Zuteilung)" type="number" size="small"
|
|
||||||
value={d.parkingSpots ?? ''}
|
|
||||||
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
helperText={property.hardFacts?.parking != null ? `aus Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
|
||||||
<TextField
|
|
||||||
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
|
|
||||||
value={d.expectedRentPerSqm ?? ''}
|
|
||||||
onChange={e => setD(p => ({ ...p, 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>
|
|
||||||
|
|
||||||
{needsBuild && (
|
{needsBuild && (
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||||
@@ -121,7 +113,7 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
{d.fitOutByLandlord === false && (
|
{d.fitOutByLandlord === false && (
|
||||||
<TextField
|
<TextField
|
||||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
|
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 150 }}
|
||||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||||
@@ -130,8 +122,9 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.25, justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, justifyContent: 'flex-end' }}>
|
||||||
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
|
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import { aiService, parseListingText } from '../services/aiService'
|
import { aiService, parseListingText } from '../services/aiService'
|
||||||
import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
|
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
|
||||||
|
|
||||||
export function useParseNeed() {
|
export function useParseNeed() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -34,3 +34,12 @@ export function useFitOutAdvice(input: FitOutAdviceInput | null) {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['preMarketRent', input],
|
||||||
|
queryFn: () => aiService.recommendPreMarketRent(input!),
|
||||||
|
enabled: !!input && !!input.city,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
||||||
|
|
||||||
describe('estimateMarketRent', () => {
|
describe('estimateMarketRent', () => {
|
||||||
|
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
|
||||||
it('flags asking rent clearly above the city median as ABOVE', () => {
|
it('flags asking rent clearly above the city median as ABOVE', () => {
|
||||||
// Zürich OFFICE median = 42
|
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 60)
|
|
||||||
expect(est).not.toBeNull()
|
expect(est).not.toBeNull()
|
||||||
expect(est!.verdict).toBe('ABOVE')
|
expect(est!.verdict).toBe('ABOVE')
|
||||||
expect(est!.deltaPct).toBeGreaterThan(5)
|
expect(est!.deltaPct).toBeGreaterThan(5)
|
||||||
expect(est!.fairRentPerSqm).toBe(42)
|
expect(est!.fairRentPerSqm).toBe(504)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flags asking rent clearly below median as BELOW', () => {
|
it('flags asking rent clearly below median as BELOW', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 30)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
|
||||||
expect(est!.verdict).toBe('BELOW')
|
expect(est!.verdict).toBe('BELOW')
|
||||||
expect(est!.deltaPct).toBeLessThan(-5)
|
expect(est!.deltaPct).toBeLessThan(-5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('treats near-median asking rent as AT (within ±5%)', () => {
|
it('treats near-median asking rent as AT (within ±5%)', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 43)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
|
||||||
expect(est!.verdict).toBe('AT')
|
expect(est!.verdict).toBe('AT')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null for unknown city', () => {
|
it('returns null for unknown city', () => {
|
||||||
expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull()
|
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suggestFutureRent indexes by the city rent trend', () => {
|
it('suggestFutureRent indexes by the city rent trend', () => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export interface CityIntelligence {
|
|||||||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||||||
dominantIndustryClusters: string[]
|
dominantIndustryClusters: string[]
|
||||||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||||||
medianRentOffice: number // CHF/m² für Bürofläche
|
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
|
||||||
medianRentLogistics: number
|
medianRentLogistics: number // CHF/m²/Monat
|
||||||
medianRentRetail: number
|
medianRentRetail: number // CHF/m²/Monat
|
||||||
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
||||||
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||||
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
||||||
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
|
|||||||
return key ? CITY_INTELLIGENCE[key] : null
|
return key ? CITY_INTELLIGENCE[key] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
|
||||||
export function getMarketRent(city: string, assetType: string): number | null {
|
export function getMarketRent(city: string, assetType: string): number | null {
|
||||||
const intel = getCityIntelligence(city)
|
const intel = getCityIntelligence(city)
|
||||||
if (!intel) return null
|
if (!intel) return null
|
||||||
if (assetType === 'OFFICE') return intel.medianRentOffice
|
const monthly =
|
||||||
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
|
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
|
||||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
: assetType === 'RETAIL' ? intel.medianRentRetail
|
||||||
return intel.medianRentOffice
|
: intel.medianRentOffice
|
||||||
|
return monthly * 12
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
|
|||||||
return getAllUnits().find(u => u.id === unitId) ?? null
|
return getAllUnits().find(u => u.id === unitId) ?? null
|
||||||
},
|
},
|
||||||
async update(unitId: string, data: Partial<PropertyUnit>) {
|
async update(unitId: string, data: Partial<PropertyUnit>) {
|
||||||
for (const prop of propertyStore) {
|
for (let i = 0; i < propertyStore.length; i++) {
|
||||||
|
const prop = propertyStore[i]
|
||||||
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
|
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
|
||||||
if (idx === -1) continue
|
if (idx === -1) continue
|
||||||
prop.units![idx] = { ...prop.units![idx], ...data }
|
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
|
||||||
return prop.units![idx]
|
// die Änderung via Structural-Sharing erkennt und neu rendert.
|
||||||
|
const nextUnits = [...prop.units!]
|
||||||
|
nextUnits[idx] = { ...nextUnits[idx], ...data }
|
||||||
|
propertyStore[i] = { ...prop, units: nextUnits }
|
||||||
|
return nextUnits[idx]
|
||||||
}
|
}
|
||||||
throw new Error(`Unit ${unitId} not found`)
|
throw new Error(`Unit ${unitId} not found`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -176,6 +176,27 @@ export interface FitOutAdvice {
|
|||||||
estimatedNetInvestment: string
|
estimatedNetInvestment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pre-market rent recommendation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PreMarketRentInput {
|
||||||
|
city: string
|
||||||
|
assetType: string
|
||||||
|
areaSqm: number
|
||||||
|
currentRentPerSqm: number
|
||||||
|
availableFrom?: string // ISO — Pre-Market liegt in der Zukunft
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreMarketRentRecommendation {
|
||||||
|
recommendedPerSqm: number
|
||||||
|
rangeMinPerSqm: number
|
||||||
|
rangeMaxPerSqm: number
|
||||||
|
verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises
|
||||||
|
deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis
|
||||||
|
drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend …
|
||||||
|
rationale: string
|
||||||
|
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
}
|
||||||
|
|
||||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||||
|
|
||||||
export interface CriteriaExtractionResult {
|
export interface CriteriaExtractionResult {
|
||||||
@@ -220,6 +241,9 @@ export interface IAIService {
|
|||||||
// Fit-out investment advice (demand side)
|
// Fit-out investment advice (demand side)
|
||||||
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
||||||
|
|
||||||
|
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
|
||||||
|
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
|
||||||
|
|
||||||
// Legacy methods
|
// Legacy methods
|
||||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
|
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
|
||||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
|
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { ServiceErrorCode } from '../../types'
|
import { ServiceErrorCode } from '../../types'
|
||||||
import { AppError } from '../../errors'
|
import { AppError } from '../../errors'
|
||||||
@@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
|||||||
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
||||||
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
||||||
import { MockAIService } from '../mock/MockAIService'
|
import { MockAIService } from '../mock/MockAIService'
|
||||||
|
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
|
|||||||
}, () => MockAIService.generateFitOutAdvice(input))
|
}, () => MockAIService.generateFitOutAdvice(input))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||||
|
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>> {
|
||||||
|
return withFallback('recommendPreMarketRent', async () => {
|
||||||
|
const intel = getCityIntelligence(input.city)
|
||||||
|
const comp = getMarketRent(input.city, input.assetType)
|
||||||
|
const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON:
|
||||||
|
{
|
||||||
|
"recommendedPerSqm": number,
|
||||||
|
"rangeMinPerSqm": number,
|
||||||
|
"rangeMaxPerSqm": number,
|
||||||
|
"verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS",
|
||||||
|
"deltaVsCurrentPct": number,
|
||||||
|
"drivers": ["kurze Treiber auf Deutsch"],
|
||||||
|
"rationale": "2-3 Sätze Begründung auf Deutsch",
|
||||||
|
"confidence": "LOW" | "MEDIUM" | "HIGH"
|
||||||
|
}`
|
||||||
|
const user = `Stadt: ${input.city}
|
||||||
|
Nutzung: ${input.assetType}
|
||||||
|
Fläche: ${input.areaSqm} m²
|
||||||
|
Heutiger Preis: CHF ${input.currentRentPerSqm}/m²
|
||||||
|
Vergleichsmiete (Median): ${comp ?? 'unbekannt'}
|
||||||
|
Leerstand: ${intel?.vacancyRatePct ?? '?'}%
|
||||||
|
Nachfrage: ${intel?.demandStrength ?? '?'}
|
||||||
|
Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
|
||||||
|
Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage`
|
||||||
|
const raw = await chat(system, user)
|
||||||
|
const json = extractJSON<PreMarketRentRecommendation>(raw)
|
||||||
|
if (!json || typeof json.recommendedPerSqm !== 'number') {
|
||||||
|
const fb = await MockAIService.recommendPreMarketRent(input)
|
||||||
|
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
|
||||||
|
}
|
||||||
|
return { data: json, provenance: makeProvenance('ai', false, true) }
|
||||||
|
}, () => MockAIService.recommendPreMarketRent(input))
|
||||||
|
},
|
||||||
|
|
||||||
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
||||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||||
return withFallback('extractCriteria', async () => {
|
return withFallback('extractCriteria', async () => {
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { mockProvenance } from '../IAIService'
|
import { mockProvenance } from '../IAIService'
|
||||||
import { aiTraceStore } from '../tracing'
|
import { aiTraceStore } from '../tracing'
|
||||||
import { mockParseNeed } from './needParser'
|
import { mockParseNeed } from './needParser'
|
||||||
import { buildComparisonSummary } from './compareBuilder'
|
import { buildComparisonSummary } from './compareBuilder'
|
||||||
import { buildMockDecisionBrief } from './decisionBrief'
|
import { buildMockDecisionBrief } from './decisionBrief'
|
||||||
|
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||||
|
|
||||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
@@ -338,6 +341,74 @@ export const MockAIService: IAIService = {
|
|||||||
return { data, provenance: mockProvenance() }
|
return { data, provenance: mockProvenance() }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||||
|
recommendPreMarketRent: (input: PreMarketRentInput) =>
|
||||||
|
traceMock('recommendPreMarketRent', async () => {
|
||||||
|
await delay(SIMULATED_DELAY.fast)
|
||||||
|
const intel = getCityIntelligence(input.city)
|
||||||
|
const comp = getMarketRent(input.city, input.assetType)
|
||||||
|
const current = input.currentRentPerSqm
|
||||||
|
const ASSET_LABELS: Record<string, string> = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' }
|
||||||
|
const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche'
|
||||||
|
|
||||||
|
// Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz
|
||||||
|
if (!intel || comp == null) {
|
||||||
|
const rec = Math.round(current * 1.02)
|
||||||
|
const data: PreMarketRentRecommendation = {
|
||||||
|
recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07),
|
||||||
|
verdict: 'FAIR', deltaVsCurrentPct: 0,
|
||||||
|
drivers: ['Keine regionalen Vergleichsdaten verfügbar'],
|
||||||
|
rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.',
|
||||||
|
confidence: 'LOW',
|
||||||
|
}
|
||||||
|
return { data, provenance: mockProvenance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
|
||||||
|
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
|
||||||
|
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
|
||||||
|
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
|
||||||
|
if (intel.vacancyRatePct < 2.5) adj += 0.04
|
||||||
|
else if (intel.vacancyRatePct < 4) adj += 0.02
|
||||||
|
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
|
||||||
|
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
|
||||||
|
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
|
||||||
|
if (intel.avgDaysOnMarket < 35) adj += 0.015
|
||||||
|
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
|
||||||
|
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
|
||||||
|
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
|
||||||
|
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: −10 % … +15 %
|
||||||
|
|
||||||
|
const recommended = Math.round(current * (1 + adj))
|
||||||
|
const rangeMin = Math.round(recommended * 0.95)
|
||||||
|
const rangeMax = Math.round(recommended * 1.05)
|
||||||
|
const deltaVsCurrentPct = Math.round(adj * 100)
|
||||||
|
const verdict: PreMarketRentRecommendation['verdict'] =
|
||||||
|
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
|
||||||
|
|
||||||
|
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
|
||||||
|
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
|
||||||
|
const drivers = [
|
||||||
|
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
|
||||||
|
demandLabel,
|
||||||
|
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
|
||||||
|
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
|
||||||
|
]
|
||||||
|
const verdictText =
|
||||||
|
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
|
||||||
|
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
|
||||||
|
: 'Heutiger Preis ist marktgerecht.'
|
||||||
|
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
|
||||||
|
const confidence: PreMarketRentRecommendation['confidence'] =
|
||||||
|
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
|
||||||
|
|
||||||
|
const data: PreMarketRentRecommendation = {
|
||||||
|
recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax,
|
||||||
|
verdict, deltaVsCurrentPct, drivers, rationale, confidence,
|
||||||
|
}
|
||||||
|
return { data, provenance: mockProvenance() }
|
||||||
|
}),
|
||||||
|
|
||||||
// Legacy methods
|
// Legacy methods
|
||||||
extractCriteria: (_input: string) =>
|
extractCriteria: (_input: string) =>
|
||||||
traceMock('extractCriteria', async () => ({
|
traceMock('extractCriteria', async () => ({
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ export type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from './ai/IAIService'
|
} from './ai/IAIService'
|
||||||
export { parseListingText } from './ai/mock/listingParser'
|
export { parseListingText } from './ai/mock/listingParser'
|
||||||
|
|||||||
@@ -87,12 +87,23 @@ function scoreProperty(
|
|||||||
return calculateScore(need, prop)
|
return calculateScore(need, prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und
|
||||||
|
// gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability).
|
||||||
|
const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE'])
|
||||||
|
|
||||||
|
function isExternalListing(prop: Property): boolean {
|
||||||
|
return prop.resultType === ResultType.VERIFIED_PORTFOLIO
|
||||||
|
&& !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
/** Generate matches for a single need against all properties and push them into matchStore. */
|
/** Generate matches for a single need against all properties and push them into matchStore. */
|
||||||
export function generateMatchesForNeed(need: Need): void {
|
export function generateMatchesForNeed(need: Need): void {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const MIN_SCORE = 22
|
const MIN_SCORE = 22
|
||||||
|
|
||||||
for (const prop of propertyStore) {
|
for (const prop of propertyStore) {
|
||||||
|
// Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen
|
||||||
|
if (isExternalListing(prop)) continue
|
||||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||||
|
|
||||||
if (hasExplicitUnits) {
|
if (hasExplicitUnits) {
|
||||||
|
|||||||
Reference in New Issue
Block a user