From 5d26dd4a6cb4311689bdf8993ea8dd5d75da4111 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 20 Jun 2026 23:06:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(supply):=20unit-centric=20fit-out=20&=20ov?= =?UTF-8?q?erview=20=E2=80=94=20per-unit=20fit-out,=20editable=20demand=20?= =?UTF-8?q?budget,=20multi-unit=20price/availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../demand/NeedExtendedRequirements.tsx | 22 +-- src/components/supply/PreMarketPanel.tsx | 23 +++ src/components/supply/PreMarketUnitGrid.tsx | 27 +++- .../supply/PropertyDetailOverview.tsx | 134 ++++++++++++------ src/components/supply/UnitStructurePanel.tsx | 13 +- src/domain/unit.ts | 3 + src/features/matching/matchCardAdapter.ts | 15 +- src/services/matchSyncService.ts | 7 +- 8 files changed, 176 insertions(+), 68 deletions(-) diff --git a/src/components/demand/NeedExtendedRequirements.tsx b/src/components/demand/NeedExtendedRequirements.tsx index b49aea2..8a338eb 100644 --- a/src/components/demand/NeedExtendedRequirements.tsx +++ b/src/components/demand/NeedExtendedRequirements.tsx @@ -73,16 +73,18 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }} /> - - Eigenes Ausbaubudget (max. CHF/m²) - set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })} - slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }} - helperText="Ihr Beitrag — exkl. MAB des Vermieters" - /> - + {c.requiredFitOut && ( + + Eigenes Ausbaubudget (max. CHF/m²) + set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })} + slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }} + helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)." + /> + + )} {/* Divisibility */} diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index 57b1b86..70a3ae6 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -27,6 +27,9 @@ export function PreMarketPanel({ p }: { p: Property }) { } return init }) + const [unitFitOut, setUnitFitOut] = useState>(() => + Object.fromEntries((p.units ?? []).map(u => [u.id, u.fitOut ?? ''])), + ) const [unitSaving, setUnitSaving] = useState>({}) const queryClient = useQueryClient() const updateProperty = useUpdateProperty() @@ -96,6 +99,23 @@ export function PreMarketPanel({ p }: { p: Property }) { } } + async function saveUnitFitOut(unitId: string, fitOut: string) { + setUnitFitOut(prev => ({ ...prev, [unitId]: fitOut })) + setUnitSaving(prev => ({ ...prev, [unitId]: true })) + try { + await MockupUnitProvider.update(unitId, { + fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined, + }) + await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) + await queryClient.invalidateQueries({ queryKey: ['properties'] }) + await queryClient.invalidateQueries({ queryKey: ['matches'] }) + } catch { + showToast('Fehler beim Speichern des Ausbaustandards.', 'error') + } finally { + setUnitSaving(prev => ({ ...prev, [unitId]: false })) + } + } + return ( <> @@ -193,6 +213,9 @@ export function PreMarketPanel({ p }: { p: Property }) { unitSaving={unitSaving} setUnitStates={setUnitStates} saveUnit={saveUnit} + propertyFitOut={p.hardFacts?.fitOut} + unitFitOut={unitFitOut} + saveUnitFitOut={saveUnitFitOut} /> )} diff --git a/src/components/supply/PreMarketUnitGrid.tsx b/src/components/supply/PreMarketUnitGrid.tsx index 5774f2f..c763cc6 100644 --- a/src/components/supply/PreMarketUnitGrid.tsx +++ b/src/components/supply/PreMarketUnitGrid.tsx @@ -1,7 +1,8 @@ -import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material' +import { Box, CircularProgress, MenuItem, Switch, TextField, Tooltip, Typography } from '@mui/material' import { EyeOff } from 'lucide-react' import type { Property } from '../../domain/property' import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds' +import { FIT_OUT_LABELS } from '../../lib/constants' import { floorLabel } from './PropertyDetailHelpers' type PropertyUnit = NonNullable[number] @@ -18,9 +19,13 @@ interface Props { unitSaving: Record setUnitStates: React.Dispatch>> saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise + propertyFitOut?: string + unitFitOut: Record + saveUnitFitOut: (unitId: string, fitOut: string) => void } -export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { +export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit, propertyFitOut, unitFitOut, saveUnitFitOut }: Props) { + const inheritLabel = propertyFitOut ? `Wie Objekt (${FIT_OUT_LABELS[propertyFitOut] ?? propertyFitOut})` : 'Wie Objekt' return ( @@ -32,7 +37,7 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates + {/* Ausbaustandard-Override (Fallback: Objekt-Wert) */} + saveUnitFitOut(u.id, e.target.value)} + > + {inheritLabel} + {FIT_OUT_LABELS.SHELL} + {FIT_OUT_LABELS.BASIC} + {FIT_OUT_LABELS.FULL} + {FIT_OUT_LABELS.PREMIUM} + + {/* Available-from date */} 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>) => + onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } }) return ( {/* Key metrics */} {[ - { 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 }) => ( {label} @@ -132,54 +159,71 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro {/* Object details */} {editing ? ( - - 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 => {o.label})} - - onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })} - /> - onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })} - /> - onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })} - /> + + + patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })} + > + {FIT_OUT_OPTIONS.map(o => {o.label})} + + patchHF({ parking: parseInt(e.target.value) || undefined })} + /> + patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })} + /> + + + {showBuildResponsibility && ( + + Wer baut aus? + + { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }} + > + Vermieter übernimmt + Mieter baut aus + + {!editByLandlord && ( + patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })} + /> + )} + + + )} ) : ( {p.propertyNumber && } - - {p.hardFacts?.mieterausbaubeitragPerSqm && ( + + {p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && ( + + )} + {p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && ( )} - + diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index 8e04e9f..12a5e76 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -229,6 +229,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { propertyId: p.id, floor: floorLabel(u), fitOut: p.hardFacts?.fitOut, + fitOutByLandlord: p.hardFacts?.fitOutByLandlord, parking: p.hardFacts?.parking, ceilingHeight: p.hardFacts?.ceilingHeightM, mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm, @@ -287,7 +288,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { )} - {/* Area */} + {/* Area + Preis + Verfügbarkeit pro Einheit */} {(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m² @@ -297,6 +298,16 @@ export function UnitStructurePanel({ p }: { p: Property }) { ab {u.minLettableSqm} m² )} + + CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m² + + {u.available && ( + + {u.schattenmarktRelease?.availableFrom + ? `ab ${new Date(u.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` + : 'sofort'} + + )} diff --git a/src/domain/unit.ts b/src/domain/unit.ts index 0a1ee9d..94ec711 100644 --- a/src/domain/unit.ts +++ b/src/domain/unit.ts @@ -17,6 +17,9 @@ export interface PropertyUnit { areaSqm: number available: boolean rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm + // 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' leases?: Lease[] // Mietverträge — current, historical, future /** @deprecated Use leases[].tenant.companyName */ currentTenant?: string diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index 9f892bb..bc46657 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -152,28 +152,29 @@ export function buildMatchCardViewModel( preMarketUnit: unit, preMarketAllUnits: property?.units, fitOutLabel: (() => { - const fitOut = property?.hardFacts?.fitOut + // Einheit-Ausbau hat Vorrang vor dem Objekt-Ausbau (Pre-Market) + const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut if (!fitOut) return undefined const LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' } - const mab = property.hardFacts?.mieterausbaubeitragPerSqm + const mab = property?.hardFacts?.mieterausbaubeitragPerSqm if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig` if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB` return LABELS[fitOut] ?? fitOut })(), fitOutViable: (() => { - const fitOut = property?.hardFacts?.fitOut + const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut if (!fitOut) return undefined if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true - const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0 + const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0 if (fitOut === 'BASIC' && mab >= 150) return true if (fitOut === 'SHELL' && mab >= 350) return true return undefined })(), fitOutInvestment: (() => { - const fitOut = property?.hardFacts?.fitOut + const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined - const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0 - const area = property.areaSqm ?? 0 + const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0 + const area = unit?.areaSqm ?? property?.areaSqm ?? 0 return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined })(), isDivisible: property?.units ? property.units.length > 1 : false, diff --git a/src/services/matchSyncService.ts b/src/services/matchSyncService.ts index 9a23600..c517d61 100644 --- a/src/services/matchSyncService.ts +++ b/src/services/matchSyncService.ts @@ -71,13 +71,16 @@ function scoreProperty( overrideArea?: number, overridePrice?: number, overrideResultType?: string, + overrideFitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM', ): MatchEngineOutput { - if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) { + if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideFitOut !== undefined) { return calculateScore(need, { ...prop, areaSqm: overrideArea ?? prop.areaSqm, rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm, resultType: (overrideResultType ?? prop.resultType) as ResultType, + // Einheit-Ausbau überschreibt den Objekt-Ausbau (Fallback bleibt prop.hardFacts.fitOut) + hardFacts: overrideFitOut ? { ...prop.hardFacts, fitOut: overrideFitOut } : prop.hardFacts, }) } return calculateScore(need, prop) @@ -101,7 +104,7 @@ export function generateMatchesForNeed(need: Need): void { } for (const unit of prop.units!) { if (!unit.schattenmarktRelease?.enabled) continue - const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY) + const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY, unit.fitOut) if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue const resultId = `schattenmarkt-${prop.id}-${unit.id}` matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))