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:
Benjamin Sutter
2026-06-20 23:44:56 +02:00
parent 647493bf3c
commit fd6ed105bb
7 changed files with 144 additions and 71 deletions
-23
View File
@@ -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}
/>
)}
+3 -24
View File
@@ -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"
+86 -5
View File
@@ -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>