refactor(supply): pre-market unit config inline — attributes first, then price (no double price)
- Released pre-market unit now shows a single always-visible config: fit-out/parking/cost-bearer (auto-save) THEN the KI price recommendation with explicit deviation vs. current price - Removes the duplicate price (base price no longer shown here; only the pre-market expected price is set), and removes the edit-only pencil for released units - Not-yet-released units keep the compact read strip + pencil editor Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,53 +1,126 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Button, Chip, CircularProgress, TextField, Typography } from '@mui/material'
|
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import type { Property, PropertyUnit } from '../../domain/property'
|
import type { Property, PropertyUnit } from '../../domain/property'
|
||||||
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
|
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
|
||||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
import { DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
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 }> = {
|
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
|
||||||
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
|
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
|
||||||
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
|
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
|
||||||
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
|
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
fitOut?: string
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
mieterausbaubeitragPerSqm?: number
|
||||||
|
parkingSpots?: number
|
||||||
|
expectedRentPerSqm?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
property: Property
|
property: Property
|
||||||
unit: PropertyUnit
|
unit: PropertyUnit
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KI-Preisempfehlung für eine Pre-Market-Einheit (regionale Vergleichsmieten, Angebot, Nachfrage). */
|
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
|
||||||
export function PreMarketPriceAdvisor({ property, unit }: Props) {
|
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 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({
|
const { data, isLoading, isError } = usePreMarketRentRecommendation({
|
||||||
city: property.location?.city ?? '',
|
city: property.location?.city ?? '',
|
||||||
assetType: property.assetType,
|
assetType: property.assetType,
|
||||||
areaSqm: unit.areaSqm,
|
areaSqm: unit.areaSqm,
|
||||||
currentRentPerSqm: facts.rentPricePerSqm,
|
currentRentPerSqm: current,
|
||||||
availableFrom: unit.schattenmarktRelease?.availableFrom,
|
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 rec = data?.data
|
||||||
const verdict = rec ? VERDICT_META[rec.verdict] : null
|
const verdict = rec ? VERDICT_META[rec.verdict] : null
|
||||||
|
|
||||||
|
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 0.75, p: 1, borderRadius: 1, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}` }}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
|
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
|
||||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
|
||||||
KI-Preisempfehlung Pre-Market
|
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
|
||||||
</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>
|
</Box>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -59,26 +132,32 @@ export function PreMarketPriceAdvisor({ property, unit }: Props) {
|
|||||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
|
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#5b21b6' }}>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
|
||||||
Empfehlung: CHF {rec.recommendedPerSqm}/m²
|
Empfehlung: CHF {rec.recommendedPerSqm}/m²
|
||||||
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm})</Box>
|
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm})</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '0.62rem', color: '#6d28d9', mb: 0.5 }}>
|
<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(' · ')}
|
{rec.drivers.join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<TextField
|
<TextField
|
||||||
type="number" size="small" label="Erwarteter Preis"
|
type="number" size="small" label="Pre-Market-Preis"
|
||||||
placeholder={`z.B. ${rec.recommendedPerSqm}`}
|
placeholder={`z.B. ${rec.recommendedPerSqm}`}
|
||||||
value={draft}
|
value={d.expectedRentPerSqm ?? ''}
|
||||||
onChange={e => setDraft(e.target.value)}
|
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
onBlur={e => saveExpected(e.target.value)}
|
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
|
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
|
||||||
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => { setDraft(String(rec.recommendedPerSqm)); saveExpected(String(rec.recommendedPerSqm)) }}
|
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
|
||||||
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
|
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
|
||||||
>
|
>
|
||||||
Empfehlung übernehmen
|
Empfehlung übernehmen
|
||||||
|
|||||||
@@ -115,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 }} />}
|
||||||
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
{!us.enabled && (
|
||||||
<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 }}>
|
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
||||||
<Pencil size={12} />
|
<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>
|
<Pencil size={12} />
|
||||||
</Tooltip>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={us.enabled}
|
checked={us.enabled}
|
||||||
@@ -139,20 +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. */}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
{us.enabled ? (
|
||||||
{detailParts.join(' · ')}
|
<PreMarketPriceAdvisor property={property} unit={u} />
|
||||||
</Typography>
|
) : (
|
||||||
|
<>
|
||||||
{/* KI-Preisempfehlung: nur wenn freigegeben */}
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
||||||
{us.enabled && <PreMarketPriceAdvisor property={property} unit={u} />}
|
{detailParts.join(' · ')}
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user