refactor(supply): per-unit details always visible + shared editor in unit list AND pre-market
- Extract UnitFieldsEditor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation, expected price) — single source for per-unit editing - UnitStructurePanel: always-visible per-unit details strip (fit-out, cost-bearer, parking, expected price) in read mode; pencil opens the shared editor - PreMarketUnitGrid: per-unit details shown + same editor reachable per unit (pencil) so fit-out/price/parking are adjustable right in the release flow - Removes the previous edit-only / fragmented per-unit fields Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
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'])
|
||||
|
||||
interface UnitDraft {
|
||||
rentPricePerSqm?: number
|
||||
availableFrom?: string
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
unit: PropertyUnit
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Shared per-unit editor: price, availability, fit-out, cost-bearer, MAB, parking allocation, expected price. */
|
||||
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||
const updateUnit = useUpdateUnit(property.id)
|
||||
const [d, setD] = useState<UnitDraft>(() => ({
|
||||
rentPricePerSqm: unit.rentPricePerSqm ?? property.rentPricePerSqm,
|
||||
availableFrom: unit.availableFrom,
|
||||
fitOut: unit.fitOut ?? '',
|
||||
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
|
||||
const nb = fit === 'SHELL' || fit === 'BASIC'
|
||||
updateUnit.mutate({
|
||||
unitId: unit.id,
|
||||
data: {
|
||||
rentPricePerSqm: d.rentPricePerSqm,
|
||||
availableFrom: d.availableFrom || undefined,
|
||||
fitOut: fit,
|
||||
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 }}>
|
||||
<TextField
|
||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||
value={d.rentPricePerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
||||
helperText="Leer = Objekt-Preis"
|
||||
/>
|
||||
{unit.available && (
|
||||
<TextField
|
||||
label="Verfügbar ab" type="date" size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={d.availableFrom ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, availableFrom: e.target.value }))}
|
||||
helperText="Leer = sofort"
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
select size="small" label="Ausbaustandard"
|
||||
value={d.fitOut ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
||||
>
|
||||
<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 (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 && (
|
||||
<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' }}>
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user