feat(ai): AI pre-market rent recommendation from regional comparables, supply & demand

- IAIService.recommendPreMarketRent: recommended price + range, verdict (UNDERPRICED/FAIR/AMBITIOUS), drivers, rationale, confidence
- MockAIService: deterministic recommendation from locationIntelligence — regional comp median, vacancy (supply), demand strength + days-on-market, rent trend (forward for pre-market)
- BackendAIService: LLM prompt with market context + mock fallback
- usePreMarketRentRecommendation hook; PreMarketPriceAdvisor component shows the recommendation per released unit with verdict ("zu günstig" when underpriced) + adjustable expected price + "Empfehlung übernehmen"
- Replaces the simple indexed suggestion with a market-driven AI recommendation that flags underpricing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-21 00:22:14 +02:00
parent 3e1e945661
commit fb029cf0bc
7 changed files with 239 additions and 53 deletions
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { Box, Button, Chip, CircularProgress, TextField, 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 { DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
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 Props {
property: Property
unit: PropertyUnit
}
/** KI-Preisempfehlung für eine Pre-Market-Einheit (regionale Vergleichsmieten, Angebot, Nachfrage). */
export function PreMarketPriceAdvisor({ property, unit }: Props) {
const facts = resolveUnitFacts(property, unit)
const { data, isLoading, isError } = usePreMarketRentRecommendation({
city: property.location?.city ?? '',
assetType: property.assetType,
areaSqm: unit.areaSqm,
currentRentPerSqm: facts.rentPricePerSqm,
availableFrom: unit.schattenmarktRelease?.availableFrom,
})
const updateUnit = useUpdateUnit(property.id)
const [draft, setDraft] = useState(unit.expectedRentPerSqm != null ? String(unit.expectedRentPerSqm) : '')
function saveExpected(value: string) {
updateUnit.mutate({ unitId: unit.id, data: { expectedRentPerSqm: parseInt(value) || undefined } })
}
const rec = data?.data
const verdict = rec ? VERDICT_META[rec.verdict] : null
return (
<Box sx={{ mt: 0.75, p: 1, borderRadius: 1, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}` }}>
<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.7rem', 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.62rem', color: '#6d28d9', mb: 0.5 }}>
{rec.drivers.join(' · ')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TextField
type="number" size="small" label="Erwarteter Preis"
placeholder={`z.B. ${rec.recommendedPerSqm}`}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={e => saveExpected(e.target.value)}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
/>
<Button
size="small"
onClick={() => { setDraft(String(rec.recommendedPerSqm)); saveExpected(String(rec.recommendedPerSqm)) }}
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
>
Empfehlung übernehmen
</Button>
</Box>
</>
)}
</Box>
)
}
+6 -52
View File
@@ -1,14 +1,13 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff, Pencil, Sparkles } from 'lucide-react'
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff, Pencil } from 'lucide-react'
import type { Property } from '../../domain/property'
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_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { suggestFutureRent } from '../../lib/rentEstimate'
import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor'
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
type PropertyUnit = NonNullable<Property['units']>[number]
@@ -29,14 +28,6 @@ interface Props {
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
const [editing, setEditing] = useState<string | null>(null)
const [expectedDraft, setExpectedDraft] = useState<Record<string, string>>(() =>
Object.fromEntries(units.map(u => [u.id, u.expectedRentPerSqm != null ? String(u.expectedRentPerSqm) : ''])),
)
const updateUnit = useUpdateUnit(property.id)
function saveExpected(unitId: string, value: string) {
updateUnit.mutate({ unitId, data: { expectedRentPerSqm: parseInt(value) || undefined } })
}
return (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
@@ -153,45 +144,8 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
{detailParts.join(' · ')}
</Typography>
{/* KI-Preis-Ansicht: nur wenn freigegeben — Vorschlag, anpassbar wenn zu günstig */}
{us.enabled && (() => {
const current = f.rentPricePerSqm
const suggestion = suggestFutureRent(property.location?.city ?? '', current)
return (
<Box sx={{ mt: 0.75, p: 1, borderRadius: 1, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}` }}>
<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>
</Box>
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.625 }}>
Heute CHF {current}/m²
{suggestion ? ` → indexiert CHF ${suggestion}/m²` : ''} anpassen, falls zu günstig.
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TextField
type="number" size="small" placeholder={suggestion ? `z.B. ${suggestion}` : 'CHF/m²'}
label="Erwarteter Preis"
value={expectedDraft[u.id] ?? ''}
onChange={e => setExpectedDraft(prev => ({ ...prev, [u.id]: e.target.value }))}
onBlur={e => saveExpected(u.id, e.target.value)}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
/>
{suggestion != null && (
<Button
size="small"
onClick={() => { setExpectedDraft(prev => ({ ...prev, [u.id]: String(suggestion) })); saveExpected(u.id, String(suggestion)) }}
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
>
Vorschlag übernehmen
</Button>
)}
</Box>
</Box>
)
})()}
{/* KI-Preisempfehlung: nur wenn freigegeben */}
{us.enabled && <PreMarketPriceAdvisor property={property} unit={u} />}
{/* Inline editor (shared) */}
<Collapse in={editing === u.id}>