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:
Benjamin Sutter
2026-06-20 23:26:45 +02:00
parent 5d26dd4a6c
commit 647493bf3c
7 changed files with 102 additions and 22 deletions
@@ -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 },
{
+59 -5
View File
@@ -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}>
+1
View File
@@ -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'
+10 -5
View File
@@ -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)
+1 -1
View File
@@ -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,