feat(supply): per-unit price/availability editor + mandatory fit-out cost-bearer for shell/edelrohbau
- 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>
This commit is contained in:
@@ -9,7 +9,7 @@ interface Props {
|
||||
onFloorChange: (v: string) => void
|
||||
fitOut: string
|
||||
onFitOutChange: (v: string) => void
|
||||
fitOutByLandlord: boolean
|
||||
fitOutByLandlord: boolean | undefined
|
||||
onFitOutByLandlordChange: (v: boolean) => void
|
||||
parking: string
|
||||
onParkingChange: (v: string) => void
|
||||
@@ -66,19 +66,25 @@ export function TechnicalDetailsSection({
|
||||
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
|
||||
{showFitOutResponsibility && (
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Wer baut aus?</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{fitOutByLandlord ? (
|
||||
{fitOutByLandlord === undefined ? (
|
||||
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
|
||||
Pflichtangabe bei Rohbau/Edelrohbau — bitte wählen, wer den Ausbau trägt.
|
||||
</Typography>
|
||||
) : fitOutByLandlord ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
|
||||
Ausbau im Mietzins enthalten — bitte die <strong>bezugsfertige</strong> Miete eintragen.
|
||||
Es wird kein Aufschlag berechnet.
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
// 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 editByLandlord = hf.fitOutByLandlord
|
||||
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
|
||||
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
|
||||
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
|
||||
@@ -187,17 +187,24 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
|
||||
{showBuildResponsibility && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Wer baut aus?</Typography>
|
||||
<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 ? 'landlord' : 'tenant'}
|
||||
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 && (
|
||||
{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 }}
|
||||
|
||||
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
|
||||
|
||||
function saveEdit() {
|
||||
if (!property) return
|
||||
// Träger-Pflicht bei Rohbau/Edelrohbau (SHELL/BASIC)
|
||||
const effFitOut = draft.hardFacts?.fitOut ?? property.hardFacts?.fitOut
|
||||
const effByLandlord = draft.hardFacts?.fitOutByLandlord ?? property.hardFacts?.fitOutByLandlord
|
||||
if ((effFitOut === 'SHELL' || effFitOut === 'BASIC') && effByLandlord === undefined) {
|
||||
showToast('Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter).', 'error')
|
||||
return
|
||||
}
|
||||
updateProperty.mutate(
|
||||
{ id: property.id, input: draft },
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import type { Lease } from '../../domain/lease'
|
||||
@@ -50,8 +50,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
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 updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
function openUnitEditor(u: PropertyUnit) {
|
||||
setUnitDraft({ rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, availableFrom: u.availableFrom })
|
||||
setEditingUnit(u.id)
|
||||
}
|
||||
function saveUnitEditor(unitId: string) {
|
||||
updateUnit.mutate({ unitId, data: { rentPricePerSqm: unitDraft.rentPricePerSqm, availableFrom: unitDraft.availableFrom || undefined } })
|
||||
setEditingUnit(null)
|
||||
}
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
const unitMatches = useUnitMatchesMap(freeUnits, p)
|
||||
@@ -286,6 +297,16 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Edit price / availability per unit */}
|
||||
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => (editingUnit === u.id ? setEditingUnit(null) : openUnitEditor(u))}
|
||||
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Area + Preis + Verfügbarkeit pro Einheit */}
|
||||
@@ -301,15 +322,48 @@ 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.available && (
|
||||
{u.available && (() => {
|
||||
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||
{u.schattenmarktRelease?.availableFrom
|
||||
? `ab ${new Date(u.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}`
|
||||
: 'sofort'}
|
||||
{av ? `ab ${new Date(av).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` : 'sofort'}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Inline editor: price + availability per unit */}
|
||||
<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: 'flex', gap: 1, mt: 1, 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>
|
||||
</Collapse>
|
||||
|
||||
{/* Expanded: matches for free units */}
|
||||
<Collapse in={isExpanded && u.available}>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PropertyUnit {
|
||||
areaSqm: number
|
||||
available: boolean
|
||||
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
||||
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
|
||||
// Unit-level fit-out override — falls back to property.hardFacts.fitOut when unset.
|
||||
// Relevant for pre-market, where units of one property are matched individually.
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
|
||||
@@ -15,6 +15,8 @@ function validate(fields: {
|
||||
city: string
|
||||
areaSqm: string
|
||||
rentPerSqm: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean | undefined
|
||||
}): string | null {
|
||||
if (!fields.street.trim()) return 'Strasse erforderlich'
|
||||
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
|
||||
@@ -23,6 +25,9 @@ function validate(fields: {
|
||||
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
||||
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
|
||||
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
|
||||
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -43,7 +48,7 @@ export interface NewListingFormState {
|
||||
// Technical
|
||||
floor: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean
|
||||
fitOutByLandlord: boolean | undefined
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
mieterausbaubeitrag: string
|
||||
@@ -81,7 +86,7 @@ export interface NewListingFormHandlers {
|
||||
setSoftLevel: (key: string, value: string) => void
|
||||
setFloor: (v: string) => void
|
||||
setFitOut: (v: string) => void
|
||||
setFitOutByLandlord: (v: boolean) => void
|
||||
setFitOutByLandlord: (v: boolean | undefined) => void
|
||||
setParking: (v: string) => void
|
||||
setCeilingHeight: (v: string) => void
|
||||
setMieterausbaubeitrag: (v: string) => void
|
||||
@@ -119,7 +124,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
|
||||
const [floor, setFloor] = useState(pre.floor ?? '')
|
||||
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
|
||||
const [fitOutByLandlord, setFitOutByLandlord] = useState(pre.fitOutByLandlord ?? false)
|
||||
const [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
|
||||
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
|
||||
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
|
||||
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
|
||||
@@ -164,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
|
||||
if (err) { setError(err); return }
|
||||
setError(null)
|
||||
createProperty.mutate(
|
||||
@@ -188,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
|
||||
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
|
||||
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
|
||||
@@ -46,7 +46,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
const hf = {
|
||||
floor: floor ? parseInt(floor) : undefined,
|
||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
||||
fitOutByLandlord: fitOutByLandlord || undefined,
|
||||
fitOutByLandlord,
|
||||
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
|
||||
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||
parking: parking ? parseInt(parking) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user