diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index 57b1b86..4d83b54 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -188,6 +188,7 @@ export function PreMarketPanel({ p }: { p: Property }) { {(p.units?.length ?? 0) > 0 && ( [number] @@ -13,6 +17,7 @@ export interface UnitReleaseState { } interface Props { + property: Property units: PropertyUnit[] unitStates: Record unitSaving: Record @@ -20,7 +25,9 @@ interface Props { saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise } -export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { +export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { + const [editing, setEditing] = useState(null) + return ( @@ -28,100 +35,120 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates {units.map(u => { const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false } + const f = resolveUnitFacts(property, u) + const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null + const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC' + const detailParts: string[] = [] + if (fitLabel) detailParts.push(fitLabel) + if (needsBuild) detailParts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`) + if (f.parkingSpots != null) detailParts.push(`${f.parkingSpots} PP`) + detailParts.push(u.expectedRentPerSqm ? `erwartet CHF ${u.expectedRentPerSqm}/m²` : `CHF ${f.rentPricePerSqm}/m²`) + return ( - - {/* Unit info + anonym toggle (shown when enabled) */} - - - {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} - - - {u.areaSqm.toLocaleString('de-CH')} m² - - {us.enabled && ( - - { - const next = { ...us, anonymous: !us.anonymous } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous) - }} - > - - - Anonym - - { - const next = { ...us, anonymous: checked } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - saveUnit(u.id, us.enabled, us.availableFrom, checked) - }} - onClick={e => e.stopPropagation()} + + + {/* Unit info + anonym toggle (shown when enabled) */} + + + {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} + + + {u.areaSqm.toLocaleString('de-CH')} m² + + {us.enabled && ( + + - - - )} - + onClick={() => { + const next = { ...us, anonymous: !us.anonymous } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous) + }} + > + + + Anonym + + { + const next = { ...us, anonymous: checked } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + saveUnit(u.id, us.enabled, us.availableFrom, checked) + }} + onClick={e => e.stopPropagation()} + sx={{ + width: 28, height: 16, p: 0, + '& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } }, + '& .MuiSwitch-thumb': { width: 12, height: 12 }, + '& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, + }} + /> + + + )} + - {/* Available-from date */} - { - const next = { ...us, availableFrom: e.target.value } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous) - }} - /> - - {/* Enabled toggle */} - - {unitSaving[u.id] && } - { - const next = { ...us, enabled: checked } + value={us.availableFrom} + disabled={!us.enabled} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }} + onChange={e => { + const next = { ...us, availableFrom: e.target.value } setUnitStates(prev => ({ ...prev, [u.id]: next })) - saveUnit(u.id, checked, us.availableFrom, us.anonymous) - }} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, + if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous) }} /> + + {/* Edit + enabled toggle */} + + {unitSaving[u.id] && } + + setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}> + + + + { + const next = { ...us, enabled: checked } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + saveUnit(u.id, checked, us.availableFrom, us.anonymous) + }} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, + }} + /> + + + {/* Always-visible per-unit details */} + + {detailParts.join(' · ')} + + + {/* Inline editor (shared) */} + + + setEditing(null)} /> + + ) })} diff --git a/src/components/supply/UnitFieldsEditor.tsx b/src/components/supply/UnitFieldsEditor.tsx new file mode 100644 index 0000000..f752871 --- /dev/null +++ b/src/components/supply/UnitFieldsEditor.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react' +import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material' +import type { Property, PropertyUnit } from '../../domain/property' +import { useUpdateUnit } from '../../hooks/useProperties' +import { FIT_OUT_LABELS } from '../../lib/constants' +import { suggestFutureRent } from '../../lib/rentEstimate' +import { DS_BORDER, DS_TEXT } from '../../lib/ds' + +const NEEDS_BUILD = new Set(['SHELL', 'BASIC']) + +interface UnitDraft { + rentPricePerSqm?: number + availableFrom?: string + fitOut?: string + fitOutByLandlord?: boolean + mieterausbaubeitragPerSqm?: number + parkingSpots?: number + expectedRentPerSqm?: number +} + +interface Props { + property: Property + unit: PropertyUnit + onClose: () => void +} + +/** Shared per-unit editor: price, availability, fit-out, cost-bearer, MAB, parking allocation, expected price. */ +export function UnitFieldsEditor({ property, unit, onClose }: Props) { + const updateUnit = useUpdateUnit(property.id) + const [d, setD] = useState(() => ({ + rentPricePerSqm: unit.rentPricePerSqm ?? property.rentPricePerSqm, + availableFrom: unit.availableFrom, + fitOut: unit.fitOut ?? '', + fitOutByLandlord: unit.fitOutByLandlord, + mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm, + parkingSpots: unit.parkingSpots, + expectedRentPerSqm: unit.expectedRentPerSqm, + })) + + const effFit = d.fitOut || property.hardFacts?.fitOut + const needsBuild = NEEDS_BUILD.has(effFit ?? '') + const suggestion = suggestFutureRent(property.location?.city ?? '', d.rentPricePerSqm ?? unit.rentPricePerSqm ?? property.rentPricePerSqm) + + function save() { + const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined + const nb = fit === 'SHELL' || fit === 'BASIC' + updateUnit.mutate({ + unitId: unit.id, + data: { + rentPricePerSqm: d.rentPricePerSqm, + availableFrom: d.availableFrom || undefined, + fitOut: fit, + fitOutByLandlord: nb ? d.fitOutByLandlord : undefined, + mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined, + parkingSpots: d.parkingSpots, + expectedRentPerSqm: d.expectedRentPerSqm, + }, + }) + onClose() + } + + return ( + + + setD(p => ({ ...p, rentPricePerSqm: parseInt(e.target.value) || undefined }))} + slotProps={{ htmlInput: { min: 0, step: 10 } }} + helperText="Leer = Objekt-Preis" + /> + {unit.available && ( + setD(p => ({ ...p, availableFrom: e.target.value }))} + helperText="Leer = sofort" + /> + )} + setD(p => ({ ...p, fitOut: e.target.value }))} + > + Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''} + {FIT_OUT_LABELS.SHELL} + {FIT_OUT_LABELS.BASIC} + {FIT_OUT_LABELS.FULL} + {FIT_OUT_LABELS.PREMIUM} + + setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))} + slotProps={{ htmlInput: { min: 0 } }} + helperText={property.hardFacts?.parking != null ? `aus Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'} + /> + + + + setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))} + slotProps={{ htmlInput: { min: 0, step: 10 } }} + helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'} + /> + + + {needsBuild && ( + + Wer baut aus? + { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }} + > + Vermieter + Mieter + + {d.fitOutByLandlord === false && ( + setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))} + slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }} + /> + )} + leer = wie Objekt + + )} + + + + + + + ) +} diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index 418506a..2016d6f 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material' +import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, 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' @@ -8,20 +8,9 @@ import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/ 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 { suggestFutureRent } from '../../lib/rentEstimate' +import { resolveUnitFacts } from '../../lib/unitFacts' 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 - expectedRentPerSqm?: number -} +import { UnitFieldsEditor } from './UnitFieldsEditor' function MatchPill({ m }: { m: UnitNeedMatch }) { const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff' @@ -65,39 +54,8 @@ export function UnitStructurePanel({ p }: { p: Property }) { const [editingFlexUnit, setEditingFlexUnit] = useState(null) const [flexDraft, setFlexDraft] = useState>({}) const [editingUnit, setEditingUnit] = useState(null) - const [unitDraft, setUnitDraft] = useState({}) const updateUnit = useUpdateUnit(p.id) - function openUnitEditor(u: PropertyUnit) { - setUnitDraft({ - rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, - availableFrom: u.availableFrom, - fitOut: u.fitOut ?? '', - fitOutByLandlord: u.fitOutByLandlord, - mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm, - parkingSpots: u.parkingSpots, - expectedRentPerSqm: u.expectedRentPerSqm, - }) - setEditingUnit(u.id) - } - function saveUnitEditor(unitId: string) { - 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, - expectedRentPerSqm: unitDraft.expectedRentPerSqm, - }, - }) - setEditingUnit(null) - } - const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units]) const unitMatches = useUnitMatchesMap(freeUnits, p) @@ -336,7 +294,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { (editingUnit === u.id ? setEditingUnit(null) : openUnitEditor(u))} + onClick={() => setEditingUnit(editingUnit === u.id ? null : u.id)} sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }} > @@ -357,11 +315,6 @@ export function UnitStructurePanel({ p }: { p: Property }) { CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m² - {u.parkingSpots != null && ( - - {u.parkingSpots} PP - - )} {u.available && (() => { const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom return ( @@ -373,96 +326,30 @@ export function UnitStructurePanel({ p }: { p: Property }) { - {/* Inline editor: price + availability per unit */} + {/* Always-visible per-unit details (read) */} + {(() => { + const f = resolveUnitFacts(p, u) + const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null + const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC' + const parts: string[] = [] + if (fitLabel) parts.push(`Ausbau: ${fitLabel}`) + if (needsBuild) parts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`) + if (needsBuild && !f.fitOutByLandlord && f.mabPerSqm > 0) parts.push(`MAB CHF ${f.mabPerSqm}/m²`) + if (f.parkingSpots != null) parts.push(`${f.parkingSpots} PP`) + if (u.expectedRentPerSqm) parts.push(`erwartet CHF ${u.expectedRentPerSqm}/m²`) + if (parts.length === 0) return null + return ( + + + {parts.join(' · ')} + + + ) + })()} + + {/* Inline editor: full per-unit fields */} - - - setUnitDraft(d => ({ ...d, rentPricePerSqm: parseInt(e.target.value) || undefined }))} - slotProps={{ htmlInput: { min: 0, step: 10 } }} - helperText="Leer = Objekt-Preis" - /> - {u.available && ( - setUnitDraft(d => ({ ...d, availableFrom: e.target.value }))} - helperText="Leer = sofort" - /> - )} - - - - setUnitDraft(d => ({ ...d, fitOut: e.target.value }))} - > - Wie Objekt{p.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut})` : ''} - {FIT_OUT_LABELS.SHELL} - {FIT_OUT_LABELS.BASIC} - {FIT_OUT_LABELS.FULL} - {FIT_OUT_LABELS.PREMIUM} - - 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'} - /> - - - {u.schattenmarktRelease?.enabled && (() => { - const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm) - return ( - - setUnitDraft(d => ({ ...d, expectedRentPerSqm: parseInt(e.target.value) || undefined }))} - slotProps={{ htmlInput: { min: 0, step: 10 } }} - helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'} - /> - - ) - })()} - - {UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && ( - - Wer baut aus? - { if (v) setUnitDraft(d => ({ ...d, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }} - > - Vermieter - Mieter - - {unitDraft.fitOutByLandlord === false && ( - setUnitDraft(d => ({ ...d, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))} - slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }} - /> - )} - leer = wie Objekt - - )} - - - - - - + setEditingUnit(null)} /> {/* Expanded: matches for free units */}