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 { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff, Pencil } from 'lucide-react'
|
||||
import { Box, Button, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff, Pencil, Sparkles } from 'lucide-react'
|
||||
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 { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { suggestFutureRent } from '../../lib/rentEstimate'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||
|
||||
@@ -27,6 +29,14 @@ 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}` }}>
|
||||
@@ -143,6 +153,46 @@ 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>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Inline editor (shared) */}
|
||||
<Collapse in={editing === u.id}>
|
||||
<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 { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { suggestFutureRent } from '../../lib/rentEstimate'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
@@ -15,7 +14,6 @@ interface UnitDraft {
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +22,7 @@ interface Props {
|
||||
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) {
|
||||
const updateUnit = useUpdateUnit(property.id)
|
||||
const [d, setD] = useState<UnitDraft>(() => ({
|
||||
@@ -34,12 +32,10 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||
fitOutByLandlord: unit.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: unit.parkingSpots,
|
||||
expectedRentPerSqm: unit.expectedRentPerSqm,
|
||||
}))
|
||||
|
||||
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||
const suggestion = suggestFutureRent(property.location?.city ?? '', d.rentPricePerSqm ?? unit.rentPricePerSqm ?? property.rentPricePerSqm)
|
||||
|
||||
function save() {
|
||||
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,
|
||||
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
||||
parkingSpots: d.parkingSpots,
|
||||
expectedRentPerSqm: d.expectedRentPerSqm,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25 }}>
|
||||
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
||||
{/* Konditionen */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
|
||||
<TextField
|
||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||
value={d.rentPricePerSqm ?? ''}
|
||||
@@ -79,7 +75,21 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||
/>
|
||||
)}
|
||||
<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 ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
||||
>
|
||||
@@ -89,49 +99,32 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||
</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'}
|
||||
/>
|
||||
|
||||
{needsBuild && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||
<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) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === '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: 150 }}
|
||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</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 && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||
<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) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === '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: 160 }}
|
||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||
</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" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
||||
</Box>
|
||||
|
||||
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
|
||||
return getAllUnits().find(u => u.id === unitId) ?? null
|
||||
},
|
||||
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)
|
||||
if (idx === -1) continue
|
||||
prop.units![idx] = { ...prop.units![idx], ...data }
|
||||
return prop.units![idx]
|
||||
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
|
||||
// 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`)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user