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:
@@ -188,6 +188,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
|
||||
{(p.units?.length ?? 0) > 0 && (
|
||||
<PreMarketUnitGrid
|
||||
property={p}
|
||||
units={p.units!}
|
||||
unitStates={unitStates}
|
||||
unitSaving={unitSaving}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff, Pencil } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||
|
||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||
|
||||
@@ -13,6 +17,7 @@ export interface UnitReleaseState {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
units: PropertyUnit[]
|
||||
unitStates: Record<string, UnitReleaseState>
|
||||
unitSaving: Record<string, boolean>
|
||||
@@ -20,7 +25,9 @@ interface Props {
|
||||
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
@@ -28,100 +35,120 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
</Typography>
|
||||
{units.map(u => {
|
||||
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false }
|
||||
const f = resolveUnitFacts(property, u)
|
||||
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
|
||||
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
|
||||
const detailParts: string[] = []
|
||||
if (fitLabel) detailParts.push(fitLabel)
|
||||
if (needsBuild) detailParts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
|
||||
if (f.parkingSpots != null) detailParts.push(`${f.parkingSpots} PP`)
|
||||
detailParts.push(u.expectedRentPerSqm ? `erwartet CHF ${u.expectedRentPerSqm}/m²` : `CHF ${f.rentPricePerSqm}/m²`)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'start', py: 0.875,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
{/* Unit info + anonym toggle (shown when enabled) */}
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
|
||||
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
|
||||
{u.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
{us.enabled && (
|
||||
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
|
||||
px: 0.75, py: 0.25, borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
|
||||
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = { ...us, anonymous: !us.anonymous }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
|
||||
}}
|
||||
>
|
||||
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
|
||||
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
|
||||
Anonym
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.anonymous}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, anonymous: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, us.enabled, us.availableFrom, checked)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
<Box key={u.id} sx={{ borderBottom: '1px solid #f3e8ff', '&:last-child': { borderBottom: 'none' }, py: 0.875 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px auto', gap: 1, alignItems: 'start' }}>
|
||||
{/* Unit info + anonym toggle (shown when enabled) */}
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
|
||||
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
|
||||
{u.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
{us.enabled && (
|
||||
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
|
||||
<Box
|
||||
sx={{
|
||||
width: 28, height: 16, p: 0,
|
||||
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
|
||||
'& .MuiSwitch-thumb': { width: 12, height: 12 },
|
||||
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
|
||||
px: 0.75, py: 0.25, borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
|
||||
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
onClick={() => {
|
||||
const next = { ...us, anonymous: !us.anonymous }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
|
||||
}}
|
||||
>
|
||||
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
|
||||
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
|
||||
Anonym
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.anonymous}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, anonymous: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, us.enabled, us.availableFrom, checked)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
sx={{
|
||||
width: 28, height: 16, p: 0,
|
||||
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
|
||||
'& .MuiSwitch-thumb': { width: 12, height: 12 },
|
||||
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Available-from date */}
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
value={us.availableFrom}
|
||||
disabled={!us.enabled}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
|
||||
onChange={e => {
|
||||
const next = { ...us, availableFrom: e.target.value }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||
<Switch
|
||||
{/* Available-from date */}
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, enabled: checked }
|
||||
value={us.availableFrom}
|
||||
disabled={!us.enabled}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
|
||||
onChange={e => {
|
||||
const next = { ...us, availableFrom: e.target.value }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit + enabled toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
||||
<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 }}>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, enabled: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Always-visible per-unit details */}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
||||
{detailParts.join(' · ')}
|
||||
</Typography>
|
||||
|
||||
{/* Inline editor (shared) */}
|
||||
<Collapse in={editing === u.id}>
|
||||
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
||||
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
@@ -8,20 +8,9 @@ import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/
|
||||
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 { suggestFutureRent } from '../../lib/rentEstimate'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface UnitDraft {
|
||||
rentPricePerSqm?: number
|
||||
availableFrom?: string
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||
|
||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||
@@ -65,39 +54,8 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||
const [unitDraft, setUnitDraft] = useState<UnitDraft>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
function openUnitEditor(u: PropertyUnit) {
|
||||
setUnitDraft({
|
||||
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||
availableFrom: u.availableFrom,
|
||||
fitOut: u.fitOut ?? '',
|
||||
fitOutByLandlord: u.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: u.parkingSpots,
|
||||
expectedRentPerSqm: u.expectedRentPerSqm,
|
||||
})
|
||||
setEditingUnit(u.id)
|
||||
}
|
||||
function saveUnitEditor(unitId: string) {
|
||||
const effFit = (unitDraft.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||
const needsBuild = effFit === 'SHELL' || effFit === 'BASIC'
|
||||
updateUnit.mutate({
|
||||
unitId,
|
||||
data: {
|
||||
rentPricePerSqm: unitDraft.rentPricePerSqm,
|
||||
availableFrom: unitDraft.availableFrom || undefined,
|
||||
fitOut: effFit,
|
||||
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
|
||||
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
|
||||
parkingSpots: unitDraft.parkingSpots,
|
||||
expectedRentPerSqm: unitDraft.expectedRentPerSqm,
|
||||
},
|
||||
})
|
||||
setEditingUnit(null)
|
||||
}
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
const unitMatches = useUnitMatchesMap(freeUnits, p)
|
||||
@@ -336,7 +294,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => (editingUnit === u.id ? setEditingUnit(null) : openUnitEditor(u))}
|
||||
onClick={() => setEditingUnit(editingUnit === u.id ? null : u.id)}
|
||||
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
@@ -357,11 +315,6 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
|
||||
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
|
||||
</Typography>
|
||||
{u.parkingSpots != null && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||
{u.parkingSpots} PP
|
||||
</Typography>
|
||||
)}
|
||||
{u.available && (() => {
|
||||
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
||||
return (
|
||||
@@ -373,96 +326,30 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Inline editor: price + availability per unit */}
|
||||
{/* Always-visible per-unit details (read) */}
|
||||
{(() => {
|
||||
const f = resolveUnitFacts(p, u)
|
||||
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
|
||||
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
|
||||
const parts: string[] = []
|
||||
if (fitLabel) parts.push(`Ausbau: ${fitLabel}`)
|
||||
if (needsBuild) parts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
|
||||
if (needsBuild && !f.fitOutByLandlord && f.mabPerSqm > 0) parts.push(`MAB CHF ${f.mabPerSqm}/m²`)
|
||||
if (f.parkingSpots != null) parts.push(`${f.parkingSpots} PP`)
|
||||
if (u.expectedRentPerSqm) parts.push(`erwartet CHF ${u.expectedRentPerSqm}/m²`)
|
||||
if (parts.length === 0) return null
|
||||
return (
|
||||
<Box sx={{ px: 1.5, pb: 0.625, mt: -0.25, borderBottom: isLastRow ? 'none' : `1px solid ${DS_BORDER.muted}` }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.63rem' }}>
|
||||
{parts.join(' · ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Inline editor: full per-unit fields */}
|
||||
<Collapse in={editingUnit === u.id}>
|
||||
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: u.available ? '1fr 1fr' : '1fr', gap: 1.25 }}>
|
||||
<TextField
|
||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||
value={unitDraft.rentPricePerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
||||
helperText="Leer = Objekt-Preis"
|
||||
/>
|
||||
{u.available && (
|
||||
<TextField
|
||||
label="Verfügbar ab" type="date" size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={unitDraft.availableFrom ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, availableFrom: e.target.value }))}
|
||||
helperText="Leer = sofort"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25, mt: 1.25 }}>
|
||||
<TextField
|
||||
select size="small" label="Ausbaustandard"
|
||||
value={unitDraft.fitOut ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, fitOut: e.target.value }))}
|
||||
>
|
||||
<MenuItem value="">Wie Objekt{p.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.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={unitDraft.parkingSpots ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
helperText={p.hardFacts?.parking != null ? `aus Objekt-Pool: ${p.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{u.schattenmarktRelease?.enabled && (() => {
|
||||
const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm)
|
||||
return (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<TextField
|
||||
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
|
||||
value={unitDraft.expectedRentPerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, 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>
|
||||
)
|
||||
})()}
|
||||
|
||||
{UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && (
|
||||
<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={unitDraft.fitOutByLandlord === undefined ? null : unitDraft.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) setUnitDraft(d => ({ ...d, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{unitDraft.fitOutByLandlord === false && (
|
||||
<TextField
|
||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
|
||||
value={unitDraft.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setUnitDraft(d => ({ ...d, 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={() => setEditingUnit(null)} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button size="small" variant="contained" onClick={() => saveUnitEditor(u.id)} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<UnitFieldsEditor property={p} unit={u} onClose={() => setEditingUnit(null)} />
|
||||
</Collapse>
|
||||
|
||||
{/* Expanded: matches for free units */}
|
||||
|
||||
Reference in New Issue
Block a user