diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index 70a3ae6..57b1b86 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -27,9 +27,6 @@ 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() @@ -99,23 +96,6 @@ 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 ( <> @@ -213,9 +193,6 @@ 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 c763cc6..5774f2f 100644 --- a/src/components/supply/PreMarketUnitGrid.tsx +++ b/src/components/supply/PreMarketUnitGrid.tsx @@ -1,8 +1,7 @@ -import { Box, CircularProgress, MenuItem, Switch, TextField, Tooltip, Typography } from '@mui/material' +import { Box, CircularProgress, 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] @@ -19,13 +18,9 @@ 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, propertyFitOut, unitFitOut, saveUnitFitOut }: Props) { - const inheritLabel = propertyFitOut ? `Wie Objekt (${FIT_OUT_LABELS[propertyFitOut] ?? propertyFitOut})` : 'Wie Objekt' +export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { return ( @@ -37,7 +32,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 */} = 85 ? '#fef3c7' : '#e0e7ff' const color = m.matchScore >= 85 ? '#92400e' : '#3730a3' @@ -51,15 +63,34 @@ export function UnitStructurePanel({ p }: { p: Property }) { const [editingFlexUnit, setEditingFlexUnit] = useState(null) const [flexDraft, setFlexDraft] = useState>({}) const [editingUnit, setEditingUnit] = useState(null) - const [unitDraft, setUnitDraft] = useState<{ rentPricePerSqm?: number; availableFrom?: string }>({}) + const [unitDraft, setUnitDraft] = useState({}) const updateUnit = useUpdateUnit(p.id) function openUnitEditor(u: PropertyUnit) { - setUnitDraft({ rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, availableFrom: u.availableFrom }) + setUnitDraft({ + rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, + availableFrom: u.availableFrom, + fitOut: u.fitOut ?? '', + fitOutByLandlord: u.fitOutByLandlord, + mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm, + parkingSpots: u.parkingSpots, + }) setEditingUnit(u.id) } function saveUnitEditor(unitId: string) { - updateUnit.mutate({ unitId, data: { rentPricePerSqm: unitDraft.rentPricePerSqm, availableFrom: unitDraft.availableFrom || undefined } }) + 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, + }, + }) setEditingUnit(null) } @@ -322,6 +353,11 @@ 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 ( @@ -354,7 +390,52 @@ export function UnitStructurePanel({ p }: { p: Property }) { /> )} - + + + 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'} + /> + + + {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 + + )} + + diff --git a/src/domain/unit.ts b/src/domain/unit.ts index 430d4a1..32eac49 100644 --- a/src/domain/unit.ts +++ b/src/domain/unit.ts @@ -18,9 +18,12 @@ export interface PropertyUnit { 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. + // Unit-level overrides — fall back to property.hardFacts.* when unset. + // Relevant where units of one property are matched individually (pre-market, multi-unit). fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' + fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert) + mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert) + parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze 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 bc46657..b121d34 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -13,6 +13,7 @@ import type { import { getCityIntelligence } from '../../lib/locationIntelligence' import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit' import { calcFitOutInvestment } from '../../lib/fitOutUtils' +import { resolveUnitFacts } from '../../lib/unitFacts' import { AvailabilityStatus } from '../../domain/enums' const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) @@ -152,30 +153,30 @@ export function buildMatchCardViewModel( preMarketUnit: unit, preMarketAllUnits: property?.units, fitOutLabel: (() => { - // Einheit-Ausbau hat Vorrang vor dem Objekt-Ausbau (Pre-Market) - const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut + // Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten) + if (!property) return undefined + const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit) if (!fitOut) return undefined const LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' } - const mab = property?.hardFacts?.mieterausbaubeitragPerSqm if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig` - if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB` + if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB` return LABELS[fitOut] ?? fitOut })(), fitOutViable: (() => { - const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut + if (!property) return undefined + const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit) if (!fitOut) return undefined if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true - const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0 - if (fitOut === 'BASIC' && mab >= 150) return true - if (fitOut === 'SHELL' && mab >= 350) return true + if (fitOut === 'BASIC' && mabPerSqm >= 150) return true + if (fitOut === 'SHELL' && mabPerSqm >= 350) return true return undefined })(), fitOutInvestment: (() => { - const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut + if (!property) return undefined + const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit) if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined - const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0 - const area = unit?.areaSqm ?? property?.areaSqm ?? 0 - return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined + const area = unit?.areaSqm ?? property.areaSqm ?? 0 + return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined })(), isDivisible: property?.units ? property.units.length > 1 : false, minDivisibleUnitSqm: (() => { diff --git a/src/lib/unitFacts.ts b/src/lib/unitFacts.ts new file mode 100644 index 0000000..6a361f1 --- /dev/null +++ b/src/lib/unitFacts.ts @@ -0,0 +1,25 @@ +import type { Property, PropertyUnit } from '../domain/property' + +export interface ResolvedUnitFacts { + fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' + mabPerSqm: number + fitOutByLandlord?: boolean + parkingSpots?: number + rentPricePerSqm: number +} + +/** + * Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert. + * Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback. + */ +export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts { + const hf = property.hardFacts + const singleUnit = (property.units?.length ?? 0) <= 1 + return { + fitOut: unit?.fitOut ?? hf?.fitOut, + mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0, + fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord, + parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined), + rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm, + } +} diff --git a/src/services/matchSyncService.ts b/src/services/matchSyncService.ts index c517d61..31b37e8 100644 --- a/src/services/matchSyncService.ts +++ b/src/services/matchSyncService.ts @@ -12,6 +12,7 @@ import type { Need } from '../domain/need' import type { Property } from '../domain/property' import { getEffectiveUnits } from '../domain/property' import { calculateScore } from '../features/matching/scoreCalculator' +import { resolveUnitFacts } from '../lib/unitFacts' function strengthFromScore(s: number): MatchStrength { if (s >= 75) return MatchStrength.STRONG @@ -71,16 +72,16 @@ function scoreProperty( overrideArea?: number, overridePrice?: number, overrideResultType?: string, - overrideFitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM', + overrideHardFacts?: Partial>, ): MatchEngineOutput { - if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideFitOut !== undefined) { + if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== 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, + // Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts) + hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts, }) } return calculateScore(need, prop) @@ -104,7 +105,13 @@ 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, unit.fitOut) + const facts = resolveUnitFacts(prop, unit) + const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY, { + fitOut: facts.fitOut, + mieterausbaubeitragPerSqm: facts.mabPerSqm, + fitOutByLandlord: facts.fitOutByLandlord, + parking: facts.parkingSpots, + }) 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))