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:
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user