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)}
+ >
+
+
+
+
+
+
+
{/* 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 => )}
-
- 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 => )}
+
+ 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))