feat(supply): per-unit fit-out/cost-bearer/MAB/parking with object fallback (resolveUnitFacts)
- unit: add fitOutByLandlord, mieterausbaubeitragPerSqm, parkingSpots (parking = allocation from object pool) - lib/unitFacts.resolveUnitFacts: single resolver (unit value ?? object value) used by scorer + card adapter - matchSyncService: pre-market unit scored via resolved facts (fit-out, MAB, cost-bearer, parking allocation) - matchCardAdapter: fit-out label/viability/investment via resolveUnitFacts - UnitStructurePanel: consolidate ALL per-unit overrides into the one expandable row editor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation); show parking on row - PreMarketUnitGrid/Panel: drop the duplicate per-unit fit-out select (now in the unit editor) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,9 +27,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
}
|
||||
return init
|
||||
})
|
||||
const [unitFitOut, setUnitFitOut] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries((p.units ?? []).map(u => [u.id, u.fitOut ?? ''])),
|
||||
)
|
||||
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
||||
const queryClient = useQueryClient()
|
||||
const updateProperty = useUpdateProperty()
|
||||
@@ -99,23 +96,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUnitFitOut(unitId: string, fitOut: string) {
|
||||
setUnitFitOut(prev => ({ ...prev, [unitId]: fitOut }))
|
||||
setUnitSaving(prev => ({ ...prev, [unitId]: true }))
|
||||
try {
|
||||
await MockupUnitProvider.update(unitId, {
|
||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['properties'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern des Ausbaustandards.', 'error')
|
||||
} finally {
|
||||
setUnitSaving(prev => ({ ...prev, [unitId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
@@ -213,9 +193,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
unitSaving={unitSaving}
|
||||
setUnitStates={setUnitStates}
|
||||
saveUnit={saveUnit}
|
||||
propertyFitOut={p.hardFacts?.fitOut}
|
||||
unitFitOut={unitFitOut}
|
||||
saveUnitFitOut={saveUnitFitOut}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Box, CircularProgress, MenuItem, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||
@@ -19,13 +18,9 @@ interface Props {
|
||||
unitSaving: Record<string, boolean>
|
||||
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, UnitReleaseState>>>
|
||||
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
|
||||
propertyFitOut?: string
|
||||
unitFitOut: Record<string, string>
|
||||
saveUnitFitOut: (unitId: string, fitOut: string) => void
|
||||
}
|
||||
|
||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit, propertyFitOut, unitFitOut, saveUnitFitOut }: Props) {
|
||||
const inheritLabel = propertyFitOut ? `Wie Objekt (${FIT_OUT_LABELS[propertyFitOut] ?? propertyFitOut})` : 'Wie Objekt'
|
||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
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 }}>
|
||||
@@ -37,7 +32,7 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 150px 140px auto',
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'start', py: 0.875,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
@@ -95,22 +90,6 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Ausbaustandard-Override (Fallback: Objekt-Wert) */}
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={unitFitOut[u.id] ?? ''}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
|
||||
onChange={e => saveUnitFitOut(u.id, e.target.value)}
|
||||
>
|
||||
<MenuItem value="" sx={{ fontSize: '0.72rem' }}>{inheritLabel}</MenuItem>
|
||||
<MenuItem value="SHELL" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||
<MenuItem value="BASIC" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||
<MenuItem value="FULL" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||
<MenuItem value="PREMIUM" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{/* Available-from date */}
|
||||
<TextField
|
||||
type="date"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, 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'
|
||||
@@ -7,8 +7,20 @@ import type { Lease } from '../../domain/lease'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
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 { 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
|
||||
}
|
||||
|
||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
|
||||
@@ -51,15 +63,34 @@ 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<{ rentPricePerSqm?: number; availableFrom?: string }>({})
|
||||
const [unitDraft, setUnitDraft] = useState<UnitDraft>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
function openUnitEditor(u: PropertyUnit) {
|
||||
setUnitDraft({ rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, availableFrom: u.availableFrom })
|
||||
setUnitDraft({
|
||||
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||
availableFrom: u.availableFrom,
|
||||
fitOut: u.fitOut ?? '',
|
||||
fitOutByLandlord: u.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: u.parkingSpots,
|
||||
})
|
||||
setEditingUnit(u.id)
|
||||
}
|
||||
function saveUnitEditor(unitId: string) {
|
||||
updateUnit.mutate({ unitId, data: { rentPricePerSqm: unitDraft.rentPricePerSqm, availableFrom: unitDraft.availableFrom || undefined } })
|
||||
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,
|
||||
},
|
||||
})
|
||||
setEditingUnit(null)
|
||||
}
|
||||
|
||||
@@ -322,6 +353,11 @@ 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 (
|
||||
@@ -354,7 +390,52 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, justifyContent: 'flex-end' }}>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user