feat(supply): unit-centric fit-out & overview — per-unit fit-out, editable demand budget, multi-unit price/availability
- domain/unit: optional per-unit fitOut override (falls back to property.hardFacts.fitOut) - matchSyncService: pre-market unit scored with its own fitOut; matchCardAdapter prefers unit fitOut - PreMarketUnitGrid/PreMarketPanel: per-unit Ausbaustandard override in the release list (default "Wie Objekt") - PropertyDetailOverview: edit/read "Wer baut aus?" for existing listings (SHELL/BASIC), central FIT_OUT_LABELS - Phase 1 unit-centric overview: header shows area+count, price as range, "Verfügbar ab → pro Einheit"; unit table shows per-unit price + availability; "Objekt & Lage" labels marked as object-defaults for multi-unit - NeedExtendedRequirements: "Eigenes Ausbaubudget" only shown when min fit-out set + clearer helper text - UnitStructurePanel: spin-off prefill carries fitOutByLandlord Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
|
||||
import { UnitStructurePanel } from './UnitStructurePanel'
|
||||
import { PreMarketPanel } from './PreMarketPanel'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
|
||||
const FIT_OUT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface PropertyDetailOverviewProps {
|
||||
p: Property
|
||||
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
|
||||
}
|
||||
|
||||
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
|
||||
const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
// Mehr-Einheiten-Objekt: Preis als Spanne, Fläche mit Anzahl, Verfügbarkeit pro Einheit
|
||||
const units = p.units ?? []
|
||||
const isMultiUnit = units.length > 1
|
||||
const unitPrices = units.map(u => u.rentPricePerSqm ?? p.rentPricePerSqm).filter(v => v > 0)
|
||||
const priceMin = unitPrices.length ? Math.min(...unitPrices) : p.rentPricePerSqm
|
||||
const priceMax = unitPrices.length ? Math.max(...unitPrices) : p.rentPricePerSqm
|
||||
const rentLabel = isMultiUnit && priceMin !== priceMax
|
||||
? `CHF ${priceMin.toLocaleString('de-CH')}–${priceMax.toLocaleString('de-CH')}`
|
||||
: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
const areaLabel = isMultiUnit
|
||||
? `${p.areaSqm.toLocaleString('de-CH')} m² · ${units.length} Einheiten`
|
||||
: `${p.areaSqm.toLocaleString('de-CH')} m²`
|
||||
const availLabel = isMultiUnit
|
||||
? 'pro Einheit ↓'
|
||||
: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined
|
||||
|
||||
// Effektive (Draft-überlagerte) hardFacts für den Bearbeiten-Modus
|
||||
const hf = draft.hardFacts ?? p.hardFacts ?? {}
|
||||
const editFitOut = hf.fitOut ?? ''
|
||||
const editByLandlord = hf.fitOutByLandlord ?? false
|
||||
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
|
||||
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
|
||||
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Key metrics */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
|
||||
{[
|
||||
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||
{ label: 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
||||
{ label: 'Fläche', value: areaLabel },
|
||||
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: availLabel },
|
||||
].map(({ label, value }) => (
|
||||
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
@@ -132,54 +159,71 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
/>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={editFitOut}
|
||||
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
|
||||
>
|
||||
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={hf.parking ?? ''}
|
||||
onChange={e => patchHF({ parking: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={hf.ceilingHeightM ?? ''}
|
||||
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showBuildResponsibility && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Wer baut aus?</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={editByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{!editByLandlord && (
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" type="number" sx={{ width: 240 }}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters — optional"
|
||||
value={hf.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
|
||||
{p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && (
|
||||
<Field label="Ausbau-Träger" value={p.hardFacts.fitOutByLandlord ? 'Vermieter (im Mietzins)' : 'Mieter'} />
|
||||
)}
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label={isMultiUnit ? 'Parkplätze (Objekt-Pool)' : 'Parkplätze'} value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
<Field label="Importiert aus" value={p.importedFrom} />
|
||||
|
||||
Reference in New Issue
Block a user