fix(supply): unit edits now persist + regroup editor + expected price as pre-market KI view

- MockupUnitProvider.update: immutable update (new unit/units/property refs) so React Query detects the change and re-renders — fixes "parking/price edit doesn't take effect"
- UnitFieldsEditor: group Ausbaustandard + Wer-baut-aus + MAB into one "Ausbau" section; conditions (price/availability/parking) on top; removed the plain expected-price field
- PreMarketUnitGrid: expected price is now a KI recommendation view (indexed suggestion + rationale) shown only for released units, with an adjustable override + "Vorschlag übernehmen"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-21 00:14:27 +02:00
parent 74f9660581
commit 3e1e945661
3 changed files with 104 additions and 56 deletions
+53 -3
View File
@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material' import { Box, Button, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff, Pencil } from 'lucide-react' import { EyeOff, Pencil, Sparkles } from 'lucide-react'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds' import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants' import { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts' import { resolveUnitFacts } from '../../lib/unitFacts'
import { suggestFutureRent } from '../../lib/rentEstimate'
import { floorLabel } from './PropertyDetailHelpers' import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor' import { UnitFieldsEditor } from './UnitFieldsEditor'
@@ -27,6 +29,14 @@ interface Props {
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
const [editing, setEditing] = useState<string | null>(null) 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 ( return (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}> <Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
@@ -143,6 +153,46 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
{detailParts.join(' · ')} {detailParts.join(' · ')}
</Typography> </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>
)
})()}
{/* Inline editor (shared) */} {/* 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' }}>
+22 -29
View File
@@ -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>
+8 -3
View File
@@ -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`)
}, },