647493bf3c
- unit.availableFrom: explicit per-unit availability date
- UnitStructurePanel: inline expandable editor (pencil) for per-unit price + availability via useUpdateUnit
- fitOutByLandlord now boolean|undefined ("not chosen"); toggle has no default for SHELL/BASIC
- NewListing: validate() blocks save until cost-bearer chosen for Rohbau/Edelrohbau
- PropertyDetailView.saveEdit: same guard for existing listings; required asterisk + hint in both forms
- newListingMapper persists explicit tenant(false) choice (was collapsed to undefined)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
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'
|
||
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
|
||
editing: boolean
|
||
draft: UpdatePropertyInput
|
||
onDraftChange: (d: UpdatePropertyInput) => void
|
||
}
|
||
|
||
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
|
||
// 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
|
||
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: 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>
|
||
<Typography variant="body1" sx={{ fontWeight: 700, mt: 0.25 }}>
|
||
{value ?? <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
{p.propertyNumber && (
|
||
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 2, display: 'block' }}>
|
||
Objekt-Nr. {p.propertyNumber}
|
||
</Typography>
|
||
)}
|
||
|
||
{/* Description */}
|
||
{editing ? (
|
||
<Box sx={{ mb: 2 }}>
|
||
<SectionTitle title="Beschreibung" />
|
||
<TextField
|
||
multiline
|
||
rows={3}
|
||
fullWidth
|
||
size="small"
|
||
value={draft.description ?? p.description ?? ''}
|
||
onChange={e => onDraftChange({ ...draft, description: e.target.value })}
|
||
placeholder="Beschreibung des Objekts…"
|
||
/>
|
||
</Box>
|
||
) : p.description ? (
|
||
<Box sx={{ mb: 2 }}>
|
||
<SectionTitle title="Beschreibung" />
|
||
<Typography variant="body2" sx={{ lineHeight: 1.6, color: 'text.secondary' }}>
|
||
{p.description}
|
||
</Typography>
|
||
</Box>
|
||
) : null}
|
||
|
||
{/* Floor / unit structure — primary tenant/lease view */}
|
||
<UnitStructurePanel p={p} />
|
||
|
||
{/* Fallback: property-level contract doc only when no unit-level contracts exist */}
|
||
{(() => {
|
||
const hasUnitContracts = p.units?.some(u => u.leases?.some(l => l.contractDocumentUrl))
|
||
if (editing) {
|
||
return (
|
||
<Box sx={{ mb: 2 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||
<FileText size={13} color="#64748b" />
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||
Gebäude-Mietvertrag (Fallback)
|
||
</Typography>
|
||
</Box>
|
||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||
<TextField
|
||
label="Vertrag URL"
|
||
size="small"
|
||
value={draft.leaseContractUrl ?? p.leaseContractUrl ?? ''}
|
||
onChange={e => onDraftChange({ ...draft, leaseContractUrl: e.target.value })}
|
||
placeholder="https://…"
|
||
slotProps={{ input: { startAdornment: <ExternalLink size={12} style={{ marginRight: 6, color: '#94a3b8', flexShrink: 0 }} /> } }}
|
||
/>
|
||
<TextField
|
||
label="Bezeichnung (optional)"
|
||
size="small"
|
||
value={draft.leaseContractName ?? p.leaseContractName ?? ''}
|
||
onChange={e => onDraftChange({ ...draft, leaseContractName: e.target.value })}
|
||
placeholder="z.B. Mietvertrag 2021–2026"
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
if (!p.leaseContractUrl || hasUnitContracts) return null
|
||
return (
|
||
<Box sx={{ mt: 0.5, mb: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: '#f8fafc' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||
<FileText size={13} color="#152642" />
|
||
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
|
||
{p.leaseContractName ?? 'Mietvertrag'}
|
||
</Typography>
|
||
</Box>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
endIcon={<ExternalLink size={11} />}
|
||
href={p.leaseContractUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||
>
|
||
Öffnen
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})()}
|
||
|
||
{/* Pre-Market Matching */}
|
||
<PreMarketPanel p={p} />
|
||
|
||
<Divider sx={{ my: 2 }} />
|
||
|
||
{/* Object details */}
|
||
<SectionTitle title="Objekt & Lage" />
|
||
{editing ? (
|
||
<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? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<ToggleButtonGroup
|
||
exclusive size="small"
|
||
value={editByLandlord === undefined ? null : 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 === undefined && (
|
||
<Typography variant="caption" sx={{ color: '#b45309' }}>
|
||
Pflichtangabe bei Rohbau/Edelrohbau
|
||
</Typography>
|
||
)}
|
||
{editByLandlord === false && (
|
||
<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={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={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} />
|
||
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||
</FieldGrid>
|
||
)}
|
||
|
||
{/* Map */}
|
||
{p.location.coordinates && (
|
||
<Box sx={{ mb: 2.5, mt: 1.5, borderRadius: 1, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
|
||
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||
Standort
|
||
</Typography>
|
||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||
{p.address.street} {p.address.houseNumber}, {p.address.postalCode} {p.address.city}
|
||
</Typography>
|
||
</Box>
|
||
<PropertyMap
|
||
lat={p.location.coordinates.lat}
|
||
lng={p.location.coordinates.lng}
|
||
label={p.title}
|
||
height={160}
|
||
/>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider sx={{ my: 1.5 }} />
|
||
|
||
{/* Data quality */}
|
||
<SectionTitle title="Datenqualität" />
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||
<Typography variant="body2" color="text.secondary">Score</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||
</Box>
|
||
<LinearProgress
|
||
variant="determinate"
|
||
value={p.dataQuality.score * 100}
|
||
color={qualityColor(p.dataQuality.score)}
|
||
sx={{ height: 8, borderRadius: 4, mb: 1 }}
|
||
/>
|
||
{p.dataQuality.warnings.length > 0 && (
|
||
<Box sx={{ mt: 1 }}>
|
||
{p.dataQuality.warnings.map((w, i) => (
|
||
<Typography key={i} variant="caption" sx={{ display: 'block', color: 'warning.main' }}>⚠ {w}</Typography>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
)
|
||
}
|