2 Commits

Author SHA1 Message Date
Benjamin Sutter 96a2507979 fix: carry all unit data into + Inserat prefill
When clicking + Inserat in UnitStructurePanel, the new listing form
now pre-populates: floor label, fit-out standard, parking, ceiling
height, Mieterausbaubeitrag, Teilbar toggle, min lettable sqm, and
availability date from the unit and its parent property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:32:54 +02:00
Benjamin Sutter 87ea4c4dfc fix: clear stale search criteria on text edit, fix Zürich false-positive, improve cost breakdown and fit-out labels
- AISearch: clear parsed criteria when user manually edits text input so stale locations (e.g. Zürich) no longer persist after retyping
- needParser: fix ZURICH_SIGNALS substring bug — "kreis N" now uses word-boundary regex so "umkreis 22" no longer matches "kreis 2"
- CompareTableBody: row 9 shows full cost breakdown (Miete + NK + amortised fit-out) using FITOUT_AMORTIZATION_YEARS from constants
- FitOutCostPanel: replace local AMORTIZATION_YEARS with FITOUT_AMORTIZATION_YEARS from constants.ts (single source of truth)
- constants.ts: add FITOUT_AMORTIZATION_YEARS = 5 — change here to affect all cost calculations
- PropertyIntelligenceCard: translate raw fitOut enum to German labels (Rohbau/Grundausbau/Vollausbau/Premium-Ausbau) with explanatory tooltips; add tooltip on contract duration chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:32:25 +02:00
9 changed files with 146 additions and 41 deletions
+60 -6
View File
@@ -20,6 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index' import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds' import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils' import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ────────────────────────────────────────────────────────────── // ── Local helper ──────────────────────────────────────────────────────────────
@@ -148,18 +150,70 @@ export function CompareTableBody({
: <MissingDataCell /> : <MissingDataCell />
}))} }))}
{/* 9. Rent / Budget Fit */} {/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
{row('9. Miete / Budget', compareItems.map(item => { {row('9. Kosten / Budget', compareItems.map(item => {
const prop = getProp(item) const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" /> if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
const monthlyNebenkosten = prop.ancillaryCosts != null
? Math.round(prop.ancillaryCosts * prop.areaSqm)
: null
const fitOut = prop.hardFacts?.fitOut
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
+ fitOutMonthly
return ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
{prop.totalRentMonthly && ( <Typography variant="caption" color="text.secondary">
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
</Typography>
{monthlyNebenkosten != null && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat + {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
</Typography>
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -2,12 +2,13 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react' import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils' import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds' import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = { const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau', SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
} }
const AMORTIZATION_YEARS = 5 const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM']) const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props { interface Props {
@@ -69,7 +70,7 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3 const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn const disclaimer = isReadyToMoveIn
? null ? null
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.' : `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
return ( return (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
@@ -1,5 +1,5 @@
import { memo } from 'react' import { memo } from 'react'
import { Box, Chip, LinearProgress, Typography } from '@mui/material' import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material'
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react' import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
@@ -153,13 +153,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small" <Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} /> sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
)} )}
{p.hardFacts?.fitOut && ( {p.hardFacts?.fitOut && (() => {
<Chip label={p.hardFacts.fitOut} size="small" const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} /> SHELL: { label: 'Rohbau', tip: 'Keine Einbauten — volle Ausbauinvestition durch Mieter erforderlich (Böden, Decken, Trennwände, TGA).' },
)} BASIC: { label: 'Grundausbau', tip: 'Grundinfrastruktur vorhanden (Böden, Beleuchtung, WCs). Ausbau für Büro/Betrieb noch nötig.' },
FULL: { label: 'Vollausbau', tip: 'Bezugsfertig ausgebaut — keine Ausbauinvestition nötig. Direkt einzugsbereit.' },
PREMIUM: { label: 'Premium-Ausbau', tip: 'Hochwertig und repräsentativ ausgebaut. Sofort bezugsfertig ohne weiteren Ausbau.' },
}
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
return (
<Tooltip title={meta.tip} placement="top" arrow>
<Chip label={meta.label} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9', cursor: 'help' }} />
</Tooltip>
)
})()}
{p.contractDurationMonths && ( {p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" <Tooltip title={`Mindest-Vertragslaufzeit: ${p.contractDurationMonths} Monate (${Math.round(p.contractDurationMonths / 12)} Jahre)`} placement="top" arrow>
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} /> <Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569', cursor: 'help' }} />
</Tooltip>
)} )}
</Box> </Box>
)} )}
+31 -12
View File
@@ -143,21 +143,30 @@ export function UnitStructurePanel({ p }: { p: Property }) {
</Box> </Box>
</Tooltip> </Tooltip>
{/* Inline min-unit editor */} {/* Inline min-unit editor — show when actively editing OR when teilbar is on but min sqm not set */}
{editingFlexUnit === u.id && ( {(editingFlexUnit === u.id || (u.isFlexible && !u.minLettableSqm)) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField <TextField
size="small" size="small"
type="number" type="number"
placeholder="Min m²" autoFocus={!u.minLettableSqm}
placeholder="Min m² *"
value={flexDraft[u.id] ?? ''} value={flexDraft[u.id] ?? ''}
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))} onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }} error={!flexDraft[u.id] && u.isFlexible && !u.minLettableSqm}
sx={{
width: 80,
'& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 },
'& .MuiOutlinedInput-root': {
'& fieldset': { borderColor: (!flexDraft[u.id] && u.isFlexible && !u.minLettableSqm) ? '#ef4444' : undefined },
},
}}
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }} slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
/> />
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
disabled={!flexDraft[u.id]}
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }} sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
onClick={() => { onClick={() => {
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } }) updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
@@ -166,14 +175,16 @@ export function UnitStructurePanel({ p }: { p: Property }) {
> >
</Button> </Button>
<Button {editingFlexUnit === u.id && u.minLettableSqm && (
size="small" <Button
variant="text" size="small"
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }} variant="text"
onClick={() => setEditingFlexUnit(null)} sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
> onClick={() => setEditingFlexUnit(null)}
>
</Button>
</Button>
)}
</Box> </Box>
)} )}
@@ -193,6 +204,14 @@ export function UnitStructurePanel({ p }: { p: Property }) {
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
unitLabel: u.unitLabel, unitLabel: u.unitLabel,
propertyId: p.id, propertyId: p.id,
floor: floorLabel(u),
fitOut: p.hardFacts?.fitOut,
parking: p.hardFacts?.parking,
ceilingHeight: p.hardFacts?.ceilingHeightM,
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
isFlexible: u.isFlexible,
minLettableSqm: u.minLettableSqm,
availableFrom: u.listing?.availableFrom ?? p.availabilityDate,
}, },
}, },
})} })}
+9 -9
View File
@@ -109,19 +109,19 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [city, setCity] = useState(pre.city ?? '') const [city, setCity] = useState(pre.city ?? '')
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '') const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '') const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
const [availableFrom, setAvailableFrom] = useState('') const [availableFrom, setAvailableFrom] = useState(pre.availableFrom ?? '')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [contactName, setContactName] = useState('') const [contactName, setContactName] = useState('')
const [contactEmail, setContactEmail] = useState('') const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('') const [contactPhone, setContactPhone] = useState('')
const [softLevels, setSoftLevels] = useState<Record<string, string>>(emptySoftLevels) const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState('') const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState('') const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [parking, setParking] = useState('') const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState('') const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState('') const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
const [isFlexible, setIsFlexible] = useState(false) const [isFlexible, setIsFlexible] = useState(pre.isFlexible ?? false)
const [minLettableSqm, setMinLettableSqm] = useState('') const [minLettableSqm, setMinLettableSqm] = useState(pre.minLettableSqm != null ? String(pre.minLettableSqm) : '')
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [imageInput, setImageInput] = useState('') const [imageInput, setImageInput] = useState('')
const [floorPlanUrl, setFloorPlanUrl] = useState('') const [floorPlanUrl, setFloorPlanUrl] = useState('')
+3
View File
@@ -139,3 +139,6 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
FULL: { min: 50, max: 200 }, FULL: { min: 50, max: 200 },
PREMIUM: { min: 0, max: 50 }, PREMIUM: { min: 0, max: 50 },
} }
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
+3
View File
@@ -56,6 +56,9 @@ export default function AISearch() {
isManualTextRef.current = text !== '' isManualTextRef.current = text !== ''
setIsAutoGen(false) setIsAutoGen(false)
setInputText(text) setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
} }
function handleAiAutofill() { function handleAiAutofill() {
+9
View File
@@ -49,6 +49,15 @@ export interface LocationState {
rentPricePerSqm?: number rentPricePerSqm?: number
unitLabel?: string unitLabel?: string
propertyId?: string propertyId?: string
floor?: string
fitOut?: string
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
isFlexible?: boolean
minLettableSqm?: number
availableFrom?: string
softLevels?: Record<string, string>
} }
} }
+8 -5
View File
@@ -47,14 +47,17 @@ export function mockParseNeed(input: string): ParseNeedResult {
return re.test(lower) return re.test(lower)
}).map(([, v]) => v) }).map(([, v]) => v)
// Infer Zürich when Zürich-specific districts or landmarks are mentioned // Infer Zürich when Zürich-specific districts or landmarks are mentioned.
const ZURICH_SIGNALS = [ // "kreis N" patterns require a word boundary so "umkreis 22" does NOT match "kreis 2".
const ZURICH_SIGNAL_WORDS = [
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west', 'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5', 'oerlikon', 'altstetten', 'langstrasse', 'hardbrücke', 'freilager',
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
'europaallee', 'zürich nord', 'zürich süd', 'europaallee', 'zürich nord', 'zürich süd',
] ]
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) { const ZURICH_KREIS_RE = /(?<![a-zA-ZäöüÄÖÜß])kreis\s+[1-8](?![0-9a-zA-ZäöüÄÖÜß])/
if (!preferredLocations.includes('Zürich') && (
ZURICH_SIGNAL_WORDS.some(s => lower.includes(s)) || ZURICH_KREIS_RE.test(lower)
)) {
preferredLocations.push('Zürich') preferredLocations.push('Zürich')
} }