3 Commits

Author SHA1 Message Date
Benjamin Sutter c6d3071010 docs: Änderungsprotokoll aktualisiert — Change 2 vollständig, Change 5 + 6 ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:24:17 +02:00
Benjamin Sutter 5a1f412ce3 feat: anonymes Suchprofil — Toggle im Speichern-Schritt, supply-side Masking
- Toggle 'Anonymes Suchprofil' im AISearchSavePreview (nicht in Suchmaske)
- Box wechselt zu lila wenn aktiv, Text erklärt was anonymisiert wird
- isAnonymous wird via buildNeedInput in gespeichertes Need geschrieben
- MatchListCard + NeedSelectionPanel: zeigen 'Anonyme Anfrage' (lila) statt Firmenname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:04:17 +02:00
Benjamin Sutter b2530f9e20 feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:09:55 +02:00
45 changed files with 1813 additions and 741 deletions
Binary file not shown.
+40 -2
View File
@@ -1,4 +1,4 @@
import { Alert, Box, Button, CircularProgress } from '@mui/material' import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
import { Save } from 'lucide-react' import { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview' import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
@@ -10,7 +10,9 @@ interface Props {
needTitle: string needTitle: string
overallConfidence: number overallConfidence: number
isSaving: boolean isSaving: boolean
isAnonymous: boolean
onNeedTitleChange: (t: string) => void onNeedTitleChange: (t: string) => void
onAnonymousChange: (v: boolean) => void
onBack: () => void onBack: () => void
onSave: () => void onSave: () => void
} }
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
needTitle, needTitle,
overallConfidence, overallConfidence,
isSaving, isSaving,
isAnonymous,
onNeedTitleChange, onNeedTitleChange,
onAnonymousChange,
onBack, onBack,
onSave, onSave,
}: Props) { }: Props) {
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
needTitle={needTitle} needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange} onNeedTitleChange={onNeedTitleChange}
/> />
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
{/* Anonymity option */}
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e2e8f0',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
<FormControlLabel
control={
<Switch
size="small"
checked={isAnonymous}
onChange={e => onAnonymousChange(e.target.checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
Anonymes Suchprofil
</Typography>
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
Firmenname wird nicht an Vermieter übermittelt Verwalter sehen nur Branche und Flächenbedarf
</Typography>
</Box>
}
/>
</Box>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}> <Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück Zurück
</Button> </Button>
@@ -1,4 +1,4 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material' import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
@@ -73,7 +73,46 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }} slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/> />
</Box> </Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
/>
</Box>
</Box> </Box>
{/* Divisibility */}
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
<FormControlLabel
control={
<Switch
size="small"
checked={c.requiresDivisibility ?? false}
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
/>
}
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
/>
{c.requiresDivisibility && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
<TextField
size="small" type="number"
placeholder="z.B. 150"
value={c.minDivisibleUnit ?? ''}
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
sx={{ width: 160 }}
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
/>
</Box>
)}
</Box>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
) )
+48 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material' import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements' import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern'] const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
function FieldLabel({ children }: { children: React.ReactNode }) { function FieldLabel({ children }: { children: React.ReactNode }) {
return ( return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))} ))}
</Stack> </Stack>
)} )}
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} /> {(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
{/* Radius */}
<Box sx={{ mb: 2.5, px: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
<Slider
value={c.searchRadius ?? 30}
min={1}
max={100}
marks={RADIUS_MARKS}
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
{/* Budget */} {/* Budget */}
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel> <FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack> </Stack>
)} )}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
Firmenname wird nicht an Vermieter übermittelt
</Typography>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={c.isAnonymous ?? false}
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
/>
}
label=""
sx={{ m: 0 }}
/>
</Box>
<NeedExtendedRequirements criteria={c} onChange={set} /> <NeedExtendedRequirements criteria={c} onChange={set} />
</Card> </Card>
) )
@@ -115,6 +115,35 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
{vm.availabilityLabel && ( {vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} /> <Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
)} )}
{vm.fitOutLabel && (
<Chip
label={vm.fitOutLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutViable ? '#059669' : '#94a3b8',
color: vm.fitOutViable ? '#059669' : '#64748b',
}}
/>
)}
{vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
<Chip
label={vm.fitOutInvestment.shortLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutInvestment.netTotal.max > 100000 ? '#d97706' : '#94a3b8',
color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
}}
/>
)}
{vm.isDivisible && vm.minDivisibleUnitSqm && (
<Chip
label={`Teilbar ab ${vm.minDivisibleUnitSqm}`}
size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
/>
)}
{topRisk && ( {topRisk && (
<Chip <Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'} label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
@@ -1,6 +1,7 @@
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match' import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
import type { PropertyUnit } from '../../domain/property' import type { PropertyUnit } from '../../domain/property'
import type { FitOutInvestment } from '../../lib/fitOutUtils'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini' export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean signalIsControlled?: boolean
signalAreaSqmEstimate?: number signalAreaSqmEstimate?: number
// Divisibility (Teilbarkeit)
isDivisible?: boolean
minDivisibleUnitSqm?: number
// Fit-out signal
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
fitOutViable?: boolean // true when budget bridges the gap
fitOutInvestment?: FitOutInvestment
// Unit-first display (primary) // Unit-first display (primary)
propertySubtitle?: string // building name shown below the unit title propertySubtitle?: string // building name shown below the unit title
@@ -70,9 +70,12 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && ( {need && (
<Chip <Chip
label={need.companyName} label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
size="small" size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }} sx={need.isAnonymous
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
: { bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }
}
/> />
)} )}
<Chip <Chip
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
}} }}
> >
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1, mr: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
{need.companyName} <Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
</Typography> {need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
</Typography>
{need.isAnonymous && (
<Chip label="Anonym" size="small" sx={{ bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 9, height: 16, fontWeight: 700, flexShrink: 0, border: '1px solid #ddd6fe' }} />
)}
</Box>
{pendingCount > 0 && ( {pendingCount > 0 && (
<Chip label={pendingCount} size="small" <Chip label={pendingCount} size="small"
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} /> sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
@@ -0,0 +1,132 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = 5
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
return (
<Box sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
</Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
{value}
</Typography>
</Box>
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
/>
)}
</Box>
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
<Row
label="Jahresmiete"
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
isTotal
isWarning={isWarning}
/>
</Box>
{disclaimer && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
{disclaimer}
</Typography>
)}
</Paper>
)
}
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
keyFacts: Array<{ label: string; value: string }> keyFacts: Array<{ label: string; value: string }>
onCompare: () => void onCompare: () => void
onShortlist: () => void onShortlist: () => void
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
} }
export function MatchDetailHero({ export function MatchDetailHero({
@@ -32,6 +36,10 @@ export function MatchDetailHero({
keyFacts, keyFacts,
onCompare, onCompare,
onShortlist, onShortlist,
searchCenterLat,
searchCenterLng,
searchRadiusKm,
searchLabel,
}: MatchDetailHeroProps) { }: MatchDetailHeroProps) {
return ( return (
<> <>
@@ -51,6 +59,10 @@ export function MatchDetailHero({
lng={property.location.coordinates.lng} lng={property.location.coordinates.lng}
label={property.title} label={property.title}
height={340} height={340}
searchCenterLat={searchCenterLat}
searchCenterLng={searchCenterLng}
searchRadiusKm={searchRadiusKm}
searchLabel={searchLabel}
/> />
) : null ) : null
)} )}
@@ -1,5 +1,5 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums' import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
@@ -63,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />} {property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />} {property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />} {property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />}
{property.hardFacts?.fitOut && (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return <KeyFactRow label="Ausbaustandard" value={mab ? `${base} + CHF ${mab}/m² MAB` : base} />
})()}
</Paper> </Paper>
{/* Eigenschaften */} {/* Eigenschaften */}
@@ -18,6 +18,7 @@ import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need' import type { Need } from '../../domain/need'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds' import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
import { lookupCityCoords } from '../../lib/locationIntelligence'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']> type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -40,6 +41,9 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
}: Props) { }: Props) {
const [showFullAnalysis, setShowFullAnalysis] = useState(false) const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
return ( return (
<> <>
{/* ── Full analysis toggle ── */} {/* ── Full analysis toggle ── */}
@@ -77,6 +81,10 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
lng={property.location.coordinates.lng} lng={property.location.coordinates.lng}
label={property.title} label={property.title}
height={220} height={220}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/> />
</Paper> </Paper>
)} )}
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material' import { Box, Chip, Paper, Typography } from '@mui/material'
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react' import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
import type { Match } from '../../domain/match' import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '' }, { icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '' },
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '' }, { icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' }, { icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
icon: <HardHat size={15} />,
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab}/m² MAB` : base
})(),
}] : []),
] ]
return ( return (
+1
View File
@@ -12,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
export { SourceProvenancePanel } from './SourceProvenancePanel' export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel' export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel' export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -1,4 +1,4 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants' import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props { interface Props {
@@ -10,6 +10,12 @@ interface Props {
onParkingChange: (v: string) => void onParkingChange: (v: string) => void
ceilingHeight: string ceilingHeight: string
onCeilingHeightChange: (v: string) => void onCeilingHeightChange: (v: string) => void
mieterausbaubeitrag: string
onMieterausbaubeitragChange: (v: string) => void
isFlexible: boolean
onIsFlexibleChange: (v: boolean) => void
minLettableSqm: string
onMinLettableSqmChange: (v: string) => void
} }
export function TechnicalDetailsSection({ export function TechnicalDetailsSection({
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
fitOut, onFitOutChange, fitOut, onFitOutChange,
parking, onParkingChange, parking, onParkingChange,
ceilingHeight, onCeilingHeightChange, ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) { }: Props) {
return ( return (
<Card sx={{ p: 3, mb: 3 }}> <Card sx={{ p: 3, mb: 3 }}>
@@ -35,6 +44,14 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem> <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))} ))}
</TextField> </TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField <TextField
label="Parkplätze" value={parking} label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)} onChange={e => onParkingChange(e.target.value)}
@@ -46,6 +63,40 @@ export function TechnicalDetailsSection({
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/> />
</Box> </Box>
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={isFlexible}
onChange={e => onIsFlexibleChange(e.target.checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Fläche ist teilbar</Typography>
<Typography variant="caption" color="text.secondary">
Mieter können auch nur einen Teil der Fläche mieten
</Typography>
</Box>
}
sx={{ m: 0, flex: 1 }}
/>
{isFlexible && (
<TextField
label="Mindesteinheit (m²)"
value={minLettableSqm}
onChange={e => onMinLettableSqmChange(e.target.value)}
size="small"
type="number"
sx={{ width: 180 }}
slotProps={{ htmlInput: { min: 10, step: 10 } }}
helperText="Kleinste vermietbare Einheit"
/>
)}
</Box>
</Card> </Card>
) )
} }
+77 -16
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { Box } from '@mui/material' import { Box, Typography } from '@mui/material'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href, shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
}) })
const searchIcon = L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:#7c3aed;border:3px solid white;box-shadow:0 2px 6px rgba(124,58,237,0.5)"></div>`,
iconAnchor: [7, 7],
})
interface Props { interface Props {
lat: number lat: number
lng: number lng: number
label?: string label?: string
height?: number height?: number
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
} }
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) { function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
return null return null
} }
export function PropertyMap({ lat, lng, label, height = 260 }: Props) { function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
propLat: number; propLng: number
searchLat: number; searchLng: number; radiusKm: number
}) {
const map = useMap()
useEffect(() => {
const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
const bounds = circleBounds.extend([propLat, propLng])
map.fitBounds(bounds, { padding: [30, 30] })
}, [map, propLat, propLng, searchLat, searchLng, radiusKm])
return null
}
export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
return ( return (
<Box <Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
sx={{ '& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
height, '& .leaflet-control': { zIndex: 1 },
width: '100%', }}>
overflow: 'hidden',
flexShrink: 0,
// Ensure leaflet controls appear above MUI elements
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}
>
<MapContainer <MapContainer
center={[lat, lng]} center={[lat, lng]}
zoom={14} zoom={hasSearch ? 10 : 14}
scrollWheelZoom scrollWheelZoom
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
> >
<RecenterOnChange lat={lat} lng={lng} /> {hasSearch
? <FitToRadius propLat={lat} propLng={lng} searchLat={searchCenterLat!} searchLng={searchCenterLng!} radiusKm={searchRadiusKm!} />
: <RecenterOnChange lat={lat} lng={lng} />
}
<TileLayer <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
{/* Search radius circle */}
{hasSearch && (
<Circle
center={[searchCenterLat!, searchCenterLng!]}
radius={searchRadiusKm! * 1000}
pathOptions={{ color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: 0.07, weight: 2, dashArray: '6 4' }}
/>
)}
{/* Search center marker */}
{hasSearch && (
<Marker position={[searchCenterLat!, searchCenterLng!]} icon={searchIcon}>
<Popup>{searchLabel ?? `Suchradius: ${searchRadiusKm} km`}</Popup>
</Marker>
)}
{/* Property marker */}
<Marker position={[lat, lng]}> <Marker position={[lat, lng]}>
{label && <Popup>{label}</Popup>} {label && <Popup>{label}</Popup>}
</Marker> </Marker>
</MapContainer> </MapContainer>
{/* Legend overlay */}
{hasSearch && (
<Box sx={{
position: 'absolute', bottom: 8, left: 8, zIndex: 500,
bgcolor: 'rgba(255,255,255,0.92)', borderRadius: 1.5,
px: 1.25, py: 0.625, display: 'flex', flexDirection: 'column', gap: 0.4,
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#7c3aed', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#5b21b6', fontWeight: 700 }}>
{searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
</Box>
</Box>
)}
</Box> </Box>
) )
} }
@@ -1,4 +1,4 @@
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material' import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react' import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property' import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared' import { PropertyMap } from '../shared'
@@ -179,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
{/* Object details */} {/* Object details */}
<SectionTitle title="Objekt & Lage" /> <SectionTitle title="Objekt & Lage" />
<FieldGrid> {editing ? (
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />} <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} /> <TextField
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} /> select label="Ausbaustandard"
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} /> size="small" fullWidth
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} /> value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} /> onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} /> >
</FieldGrid> {[
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basisausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters zum Ausbau"
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Deckenhöhe (m)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
/>
</Box>
) : (
<FieldGrid>
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
{p.hardFacts?.mieterausbaubeitragPerSqm && (
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
)}
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
</FieldGrid>
)}
{/* Map */} {/* Map */}
{p.location.coordinates && ( {p.location.coordinates && (
+63 -3
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material' import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react' import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property' import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches' import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers' import { floorLabel } from './PropertyDetailHelpers'
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const navigate = useNavigate() const navigate = useNavigate()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [expandedUnit, setExpandedUnit] = useState<string | null>(null) const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
const updateUnit = useUpdateUnit(p.id)
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units]) const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{u.available ? ( {u.available ? (
<> <>
<Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} /> <Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} />
{u.isFlexible && (
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: DS_SURFACE.blue.bg, color: '#1d4ed8', border: `1px solid ${DS_SURFACE.blue.border}` }} /> {/* Teilbar toggle */}
<Tooltip title={u.isFlexible ? 'Teilbar — Mindesteinheit bearbeiten' : 'Als teilbar markieren'}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Switch
size="small"
checked={u.isFlexible ?? false}
onChange={e => {
const flexible = e.target.checked
if (flexible) {
setEditingFlexUnit(u.id)
setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
} else {
updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
setEditingFlexUnit(null)
}
}}
sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
/>
<Typography sx={{ fontSize: '0.58rem', color: u.isFlexible ? '#1d4ed8' : DS_TEXT.disabled, fontWeight: u.isFlexible ? 700 : 400 }}>
Teilbar
</Typography>
</Box>
</Tooltip>
{/* Inline min-unit editor */}
{editingFlexUnit === u.id && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField
size="small"
type="number"
placeholder="Min m²"
value={flexDraft[u.id] ?? ''}
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 } }}
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
/>
<Button
size="small"
variant="contained"
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
onClick={() => {
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
setEditingFlexUnit(null)
}}
>
</Button>
<Button
size="small"
variant="text"
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
onClick={() => setEditingFlexUnit(null)}
>
</Button>
</Box>
)} )}
<Button <Button
size="small" size="small"
variant="text" variant="text"
+5
View File
@@ -92,6 +92,11 @@ export interface Need {
requireBarrierFree?: boolean requireBarrierFree?: boolean
minCeilingHeightM?: number minCeilingHeightM?: number
minContractDurationMonths?: number minContractDurationMonths?: number
searchRadius?: number
isAnonymous?: boolean
requiresDivisibility?: boolean
minDivisibleUnit?: number
fitOutBudgetMaxPerSqm?: number
softFactors?: SoftFactorPreferences softFactors?: SoftFactorPreferences
weightingProfile: WeightingProfile weightingProfile: WeightingProfile
+5
View File
@@ -26,6 +26,11 @@ export interface ParsedNeedCriteria {
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH' footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
companyName?: string companyName?: string
notes?: string notes?: string
searchRadius?: number
isAnonymous?: boolean
requiresDivisibility?: boolean
minDivisibleUnit?: number
fitOutBudgetMaxPerSqm?: number
} }
export interface FollowUpQuestion { export interface FollowUpQuestion {
+1
View File
@@ -54,6 +54,7 @@ export interface DataQuality {
export interface PropertyHardFacts { export interface PropertyHardFacts {
floor?: number floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
parking?: number parking?: number
publicTransportScore?: number publicTransportScore?: number
usageType?: string usageType?: string
+33
View File
@@ -12,6 +12,7 @@ import type {
} from '../../components/match-card/MatchCardViewModel' } from '../../components/match-card/MatchCardViewModel'
import { getCityIntelligence } from '../../lib/locationIntelligence' import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit' import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -137,6 +138,38 @@ export function buildMatchCardViewModel(
unitId: match.unitId ?? unit?.id ?? signal?.unitId, unitId: match.unitId ?? unit?.id ?? signal?.unitId,
preMarketUnit: unit, preMarketUnit: unit,
preMarketAllUnits: property?.units, preMarketAllUnits: property?.units,
fitOutLabel: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!fitOut) return undefined
const LABELS: Record<string, string> = { 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`
return LABELS[fitOut] ?? fitOut
})(),
fitOutViable: (() => {
const fitOut = property?.hardFacts?.fitOut
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
return undefined
})(),
fitOutInvestment: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const area = property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
})(),
isDivisible: property?.units ? property.units.length > 1 : false,
minDivisibleUnitSqm: (() => {
if (!property?.units || property.units.length <= 1) return undefined
const flexible = property.units.filter(u => u.isFlexible && u.minLettableSqm)
if (flexible.length > 0) return Math.min(...flexible.map(u => u.minLettableSqm!))
return Math.min(...property.units.map(u => u.areaSqm))
})(),
scoreBreakdown: { scoreBreakdown: {
hardMatchScore: match.scoreBreakdown.hardMatchScore, hardMatchScore: match.scoreBreakdown.hardMatchScore,
softFactorScore: match.scoreBreakdown.softFactorScore, softFactorScore: match.scoreBreakdown.softFactorScore,
+66
View File
@@ -106,6 +106,22 @@ const RULES: KeywordRule[] = [
check: (_p) => null, // no structured field yet — always UNKNOWN check: (_p) => null, // no structured field yet — always UNKNOWN
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst', explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
}, },
{
keywords: ['ausbaugrad', 'ausbaustandard', 'vollausbau', 'modern ausgebaut', 'plug and play', 'bezugsfertig', 'gehoben ausgebaut'],
check: (p) => {
const fitOut = p.hardFacts?.fitOut
if (!fitOut) return null
return fitOut === 'FULL' || fitOut === 'PREMIUM'
},
explanation: (p, ok) => {
const fitOut = p.hardFacts?.fitOut
if (!fitOut) return 'Ausbaustandard nicht dokumentiert'
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
return ok
? `${LABELS[fitOut] ?? fitOut} — moderner Ausbaustandard erfüllt`
: `${LABELS[fitOut] ?? fitOut} — nicht auf modernem Ausbauniveau`
},
},
] ]
// ── Number extraction helpers ────────────────────────────────────────────────── // ── Number extraction helpers ──────────────────────────────────────────────────
@@ -115,10 +131,25 @@ function extractNumber(text: string): number | null {
return m ? parseInt(m[1], 10) : null return m ? parseInt(m[1], 10) : null
} }
function extractMinuteLimit(text: string): number | null {
const m = text.match(/(?:unter|<|≤|max\.?)\s*(\d+)/) ?? text.match(/(\d+)\s*min/i)
return m ? parseInt(m[1], 10) : null
}
function isParkingKeyword(text: string): boolean { function isParkingKeyword(text: string): boolean {
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw)) return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
} }
function isOevKeyword(text: string): boolean {
return text.includes('öv-anbindung') || text.includes('öv anbindung') ||
text.includes('öv-anschluss') || text.includes('öffentlicher verkehr') ||
(text.includes('öv') && (text.includes('min') || text.includes('anbindung')))
}
function isMindestflaecheKeyword(text: string): boolean {
return text.includes('mindestfläche') || text.includes('mindestnutzfläche') || text.includes('mindest-fläche')
}
// ── Main scorer ─────────────────────────────────────────────────────────────── // ── Main scorer ───────────────────────────────────────────────────────────────
export function scoreMustHaves( export function scoreMustHaves(
@@ -137,6 +168,41 @@ export function scoreMustHaves(
const results: MustHaveResult[] = criteria.map((raw) => { const results: MustHaveResult[] = criteria.map((raw) => {
const lower = raw.toLowerCase() const lower = raw.toLowerCase()
// Special case: ÖV-Anbindung with minute limit ("< 5 Min", "unter 10 Min")
if (isOevKeyword(lower)) {
const minutes = property.softFactors?.publicTransportMinutes
if (minutes === undefined) {
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'ÖV-Fahrzeit nicht dokumentiert' }
}
const limit = extractMinuteLimit(lower) ?? 10
const passed = minutes <= limit
return {
criterion: raw,
passed,
confidence: 'CERTAIN' as const,
explanation: passed
? `${minutes} Min. zu Fuss zum ÖV — Limit ${limit} Min. erfüllt`
: `${minutes} Min. zu Fuss — überschreitet ${limit}-Min.-Limit`,
}
}
// Special case: Mindestfläche with m² threshold ("Mindestfläche 600m²")
if (isMindestflaecheKeyword(lower)) {
const required = extractNumber(lower)
if (required === null) {
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'Mindestfläche konnte nicht ausgelesen werden' }
}
const passed = property.areaSqm >= required
return {
criterion: raw,
passed,
confidence: 'CERTAIN' as const,
explanation: passed
? `${property.areaSqm.toLocaleString('de-CH')} m² deckt Mindestfläche ${required} m² ab`
: `${property.areaSqm.toLocaleString('de-CH')} m² liegt unter Mindestfläche ${required}`,
}
}
// Special case: parking with minimum count ("mind. 5 parkplätze") // Special case: parking with minimum count ("mind. 5 parkplätze")
if (isParkingKeyword(lower)) { if (isParkingKeyword(lower)) {
const required = extractNumber(lower) const required = extractNumber(lower)
+35 -9
View File
@@ -463,24 +463,50 @@ function scoreMinContractDuration(need: Need, property: Property): ScoreFactor |
} }
} }
const FIT_OUT_UPGRADE_COST: Record<string, number> = {
'SHELL_BASIC': 200,
'SHELL_FULL': 350,
'SHELL_PREMIUM': 450,
'BASIC_FULL': 150,
'BASIC_PREMIUM': 250,
'FULL_PREMIUM': 100,
}
function scoreFitOut(need: Need, property: Property): ScoreFactor | null { function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
if (!need.requiredFitOut) return null if (!need.requiredFitOut) return null
const propFitOut = property.hardFacts?.fitOut const propFitOut = property.hardFacts?.fitOut
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
if (!propFitOut) { if (!propFitOut) {
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true } return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
} }
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1 const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0 const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25)) if (propLevel >= reqLevel) {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' } return {
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
}
}
// Check if combined budget (tenant + MAB) covers the upgrade gap
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
const upgradeCost = FIT_OUT_UPGRADE_COST[upgradeKey]
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const tenantBudget = need.fitOutBudgetMaxPerSqm ?? 0
const totalBudget = mab + tenantBudget
if (upgradeCost && totalBudget >= upgradeCost) {
const mabNote = mab > 0 ? ` (inkl. CHF ${mab} MAB)` : ''
return {
criterion: 'fitOut', weight: 0.04, score: 80, contribution: 3.2,
explanation: `${LABELS[propFitOut]} — Ausbau auf ${LABELS[need.requiredFitOut]} mit CHF ${totalBudget}/m² Budget überbrückbar${mabNote}`,
}
}
const score = Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
const budgetNote = tenantBudget > 0 || mab > 0
? ` (Budget CHF ${totalBudget}/m² — Ausbau ~CHF ${upgradeCost ?? '?'}/m²)`
: ''
return { return {
criterion: 'fitOut', criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04,
weight: 0.04, explanation: `${LABELS[propFitOut] ?? propFitOut}${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich${budgetNote}`,
score,
contribution: score * 0.04,
explanation: propLevel >= reqLevel
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
: `${LABELS[propFitOut] ?? propFitOut}${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
} }
} }
+13 -2
View File
@@ -45,6 +45,9 @@ export interface NewListingFormState {
fitOut: string fitOut: string
parking: string parking: string
ceilingHeight: string ceilingHeight: string
mieterausbaubeitrag: string
isFlexible: boolean
minLettableSqm: string
// Contact // Contact
contactName: string contactName: string
contactEmail: string contactEmail: string
@@ -79,6 +82,9 @@ export interface NewListingFormHandlers {
setFitOut: (v: string) => void setFitOut: (v: string) => void
setParking: (v: string) => void setParking: (v: string) => void
setCeilingHeight: (v: string) => void setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
setIsFlexible: (v: boolean) => void
setMinLettableSqm: (v: string) => void
setContactName: (v: string) => void setContactName: (v: string) => void
setContactEmail: (v: string) => void setContactEmail: (v: string) => void
setContactPhone: (v: string) => void setContactPhone: (v: string) => void
@@ -113,6 +119,9 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [fitOut, setFitOut] = useState('') const [fitOut, setFitOut] = useState('')
const [parking, setParking] = useState('') const [parking, setParking] = useState('')
const [ceilingHeight, setCeilingHeight]= useState('') const [ceilingHeight, setCeilingHeight]= useState('')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState('')
const [isFlexible, setIsFlexible] = useState(false)
const [minLettableSqm, setMinLettableSqm] = useState('')
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('')
@@ -161,6 +170,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm), areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels, availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}), }),
{ {
onSuccess: () => setCreated(true), onSuccess: () => setCreated(true),
@@ -176,6 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setContactName(''); setContactEmail(''); setContactPhone('') setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels()) setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('') setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('') setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false) setAiText(''); setAiApplied(false)
setCreated(false); setError(null) setCreated(false); setError(null)
@@ -184,7 +195,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return { return {
assetType, areaSqm, rentPerSqm, availableFrom, description, assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city, street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone, contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied, images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created, error, created,
@@ -194,7 +205,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription, setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity, setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel, setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone, setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText, setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage, addImage, removeImage,
+14
View File
@@ -1,5 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { propertyService } from '../services/propertyService' import { propertyService } from '../services/propertyService'
import { unitService } from '../services/unitService'
import type { PropertyUnit } from '../domain/property'
import { matchService } from '../services/matchService' import { matchService } from '../services/matchService'
import { futureSignalService } from '../services/futureSignalService' import { futureSignalService } from '../services/futureSignalService'
import type { AssetType, ResultType } from '../domain/enums' import type { AssetType, ResultType } from '../domain/enums'
@@ -89,6 +91,18 @@ export function useCreateProperty() {
}) })
} }
export function useUpdateUnit(propertyId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ unitId, data }: { unitId: string; data: Partial<PropertyUnit> }) =>
unitService.update(unitId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['properties'] })
queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
},
})
}
export function useRemoveProperty() { export function useRemoveProperty() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
+8
View File
@@ -131,3 +131,11 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
LOW: 'Niedrig', LOW: 'Niedrig',
INCOMPLETE: 'Unvollständig', INCOMPLETE: 'Unvollständig',
} }
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
SHELL: { min: 800, max: 1500 },
BASIC: { min: 400, max: 800 },
FULL: { min: 50, max: 200 },
PREMIUM: { min: 0, max: 50 },
}
+58
View File
@@ -0,0 +1,58 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
}
export function calcFitOutInvestment(
fitOut: string,
areaSqm: number,
mabPerSqm: number,
tenantBudgetPerSqm: number,
): FitOutInvestment | null {
const benchmarks = FIT_OUT_COST_CHF_PER_SQM[fitOut]
if (!benchmarks) return null
const gross = benchmarks
const totalOffset = mabPerSqm + tenantBudgetPerSqm
const netMin = Math.max(0, gross.min - totalOffset)
const netMax = Math.max(0, gross.max - totalOffset)
const netTotal = {
min: Math.round(netMin * areaSqm),
max: Math.round(netMax * areaSqm),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
? 'Investition gedeckt'
: netTotal.min === netTotal.max
? `Est. ${formatChfK(netTotal.min)}`
: `Est. ${formatChfK(netTotal.min)}${formatChfK(netTotal.max)}`
const detailLabel = isFullyCovered
? 'Ausbaukosten durch MAB/Budget gedeckt'
: `CHF ${netMin.toLocaleString('de-CH')}${netMax.toLocaleString('de-CH')}/m² Nettoinvestition`
return {
grossPerSqm: gross,
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
isFullyCovered,
shortLabel,
detailLabel,
}
}
+31
View File
@@ -145,3 +145,34 @@ export function getMarketRent(city: string, assetType: string): number | null {
if (assetType === 'RETAIL') return intel.medianRentRetail if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice return intel.medianRentOffice
} }
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
'Zürich': { lat: 47.3769, lng: 8.5417 },
'Zürich-West': { lat: 47.3862, lng: 8.5195 },
'Zürich-Nord': { lat: 47.4108, lng: 8.5450 },
'Basel': { lat: 47.5596, lng: 7.5886 },
'Bern': { lat: 46.9480, lng: 7.4474 },
'Luzern': { lat: 47.0502, lng: 8.3093 },
'Genf': { lat: 46.2044, lng: 6.1432 },
'Lausanne': { lat: 46.5197, lng: 6.6323 },
'Zug': { lat: 47.1661, lng: 8.5158 },
'Winterthur': { lat: 47.5004, lng: 8.7256 },
'St. Gallen': { lat: 47.4245, lng: 9.3767 },
'Lugano': { lat: 46.0037, lng: 8.9511 },
'Baar': { lat: 47.1965, lng: 8.5286 },
'Muttenz': { lat: 47.5218, lng: 7.6468 },
'Pratteln': { lat: 47.5163, lng: 7.6926 },
'Reinach BL': { lat: 47.4948, lng: 7.5946 },
'Allschwil': { lat: 47.5529, lng: 7.5368 },
'Binningen': { lat: 47.5363, lng: 7.5687 },
}
export function lookupCityCoords(location: string): { lat: number; lng: number } | null {
if (CITY_COORDS[location]) return CITY_COORDS[location]
// Prefix fallback: "Zürich Kreis 5" → "Zürich"
const base = location.split(' ')[0]
const key = Object.keys(CITY_COORDS).find(k => k.startsWith(base) || base === k)
return key ? CITY_COORDS[key] : null
}
+8
View File
@@ -30,6 +30,7 @@ export const mockNeeds: Need[] = [
prestige: 0.08, accessibility: 0.10, expansionPotential: 0.04, flexibility: 0.05, prestige: 0.08, accessibility: 0.10, expansionPotential: 0.04, flexibility: 0.05,
visibility: 0.02, footfall: 0.01, talentAccess: 0.06, esg: 0.02, taxEnvironment: 0.00, visibility: 0.02, footfall: 0.01, talentAccess: 0.06, esg: 0.02, taxEnvironment: 0.00,
}, },
searchRadius: 30,
confidenceInCriteria: 0.88, confidenceInCriteria: 0.88,
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700900m², Budget max CHF 42/m², Bezug Herbst 2025.', extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700900m², Budget max CHF 42/m², Bezug Herbst 2025.',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
@@ -61,6 +62,10 @@ export const mockNeeds: Need[] = [
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.05, flexibility: 0.03, prestige: 0.02, accessibility: 0.10, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03, visibility: 0.01, footfall: 0.00, talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03,
}, },
searchRadius: 50,
requiresDivisibility: true,
minDivisibleUnit: 500,
fitOutBudgetMaxPerSqm: 120,
confidenceInCriteria: 0.94, confidenceInCriteria: 0.94,
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-03-20T14:00:00Z', createdAt: '2025-03-20T14:00:00Z',
@@ -95,6 +100,9 @@ export const mockNeeds: Need[] = [
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02, prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
visibility: 0.02, footfall: 0.01, talentAccess: 0.08, esg: 0.05, taxEnvironment: 0.00, visibility: 0.02, footfall: 0.01, talentAccess: 0.08, esg: 0.05, taxEnvironment: 0.00,
}, },
searchRadius: 20,
isAnonymous: true,
fitOutBudgetMaxPerSqm: 250,
confidenceInCriteria: 0.91, confidenceInCriteria: 0.91,
extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500800m², max. CHF 38/m², Bezug Q4 2025.', extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500800m², max. CHF 38/m², Bezug Q4 2025.',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
+35 -13
View File
@@ -95,7 +95,7 @@ export const mockProperties: Property[] = [
parkingSpots: 30, parkingSpots: 30,
publicTransportMinutes: 12, publicTransportMinutes: 12,
}, },
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
expansionPotentialSqm: 800, expansionPotentialSqm: 800,
contractDurationMonths: 36, contractDurationMonths: 36,
@@ -153,6 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8, parkingSpots: 8,
publicTransportMinutes: 5, publicTransportMinutes: 5,
}, },
hardFacts: { fitOut: 'BASIC' },
floorLevel: 2, floorLevel: 2,
expansionPotentialSqm: 200, expansionPotentialSqm: 200,
contractDurationMonths: 48, contractDurationMonths: 48,
@@ -212,6 +213,7 @@ export const mockProperties: Property[] = [
parkingSpots: 14, parkingSpots: 14,
publicTransportMinutes: 8, publicTransportMinutes: 8,
}, },
hardFacts: { fitOut: 'FULL' },
floorLevel: 4, floorLevel: 4,
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 4.5, ancillaryCosts: 4.5,
@@ -268,7 +270,7 @@ export const mockProperties: Property[] = [
parkingSpots: 25, parkingSpots: 25,
publicTransportMinutes: 14, publicTransportMinutes: 14,
}, },
hardFacts: { floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
expansionPotentialSqm: 600, expansionPotentialSqm: 600,
contractDurationMonths: 60, contractDurationMonths: 60,
@@ -380,6 +382,7 @@ export const mockProperties: Property[] = [
parkingSpots: 40, parkingSpots: 40,
publicTransportMinutes: 18, publicTransportMinutes: 18,
}, },
hardFacts: { fitOut: 'SHELL' },
floorLevel: 0, floorLevel: 0,
expansionPotentialSqm: 1200, expansionPotentialSqm: 1200,
contractDurationMonths: 120, contractDurationMonths: 120,
@@ -437,6 +440,7 @@ export const mockProperties: Property[] = [
parkingSpots: 6, parkingSpots: 6,
publicTransportMinutes: 6, publicTransportMinutes: 6,
}, },
hardFacts: { fitOut: 'FULL' },
floorLevel: 5, floorLevel: 5,
contractDurationMonths: 36, contractDurationMonths: 36,
ancillaryCosts: 6.0, ancillaryCosts: 6.0,
@@ -495,6 +499,7 @@ export const mockProperties: Property[] = [
parkingSpots: 18, parkingSpots: 18,
publicTransportMinutes: 7, publicTransportMinutes: 7,
}, },
hardFacts: { fitOut: 'BASIC' },
floorLevel: 1, floorLevel: 1,
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 5.0, ancillaryCosts: 5.0,
@@ -551,6 +556,7 @@ export const mockProperties: Property[] = [
parkingSpots: 45, parkingSpots: 45,
publicTransportMinutes: 16, publicTransportMinutes: 16,
}, },
hardFacts: { fitOut: 'SHELL' },
floorLevel: 0, floorLevel: 0,
expansionPotentialSqm: 1500, expansionPotentialSqm: 1500,
contractDurationMonths: 60, contractDurationMonths: 60,
@@ -889,7 +895,7 @@ export const mockProperties: Property[] = [
parkingSpots: 4, parkingSpots: 4,
publicTransportMinutes: 6, publicTransportMinutes: 6,
}, },
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2 }, hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2, mieterausbaubeitragPerSqm: 200 },
floorLevel: 1, floorLevel: 1,
expansionPotentialSqm: 90, expansionPotentialSqm: 90,
contractDurationMonths: 84, contractDurationMonths: 84,
@@ -1048,7 +1054,7 @@ export const mockProperties: Property[] = [
parkingSpots: 4, parkingSpots: 4,
publicTransportMinutes: 7, publicTransportMinutes: 7,
}, },
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7 }, hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7, mieterausbaubeitragPerSqm: 150 },
floorLevel: 1, floorLevel: 1,
expansionPotentialSqm: 80, expansionPotentialSqm: 80,
contractDurationMonths: 84, contractDurationMonths: 84,
@@ -1112,6 +1118,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-003', propertyNumber: 'BE-EXT-003',
description: 'Attraktive Ladenfläche an der Bahnhofstrasse Bern, direkt beim Hauptbahnhof. EG, sehr hohe Laufkundschaft, maximale Visibilität. Ideal für bekannte Handelsmarken oder Dienstleister mit direktem Kundenkontakt.', description: 'Attraktive Ladenfläche an der Bahnhofstrasse Bern, direkt beim Hauptbahnhof. EG, sehr hohe Laufkundschaft, maximale Visibilität. Ideal für bekannte Handelsmarken oder Dienstleister mit direktem Kundenkontakt.',
hardFacts: { fitOut: 'FULL' },
units: [ units: [
{ id: 'unit-003-1', propertyId: 'prop-003', floorLevel: 0, areaSqm: 320, available: true, rentPricePerSqm: 1140 }, { id: 'unit-003-1', propertyId: 'prop-003', floorLevel: 0, areaSqm: 320, available: true, rentPricePerSqm: 1140 },
], ],
@@ -1149,6 +1156,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-004', propertyNumber: 'ZH-EXT-004',
description: 'Grosszügige Gewerbefläche an der Europaallee, Zürich Kreis 4, direkt beim Hauptbahnhof. Modernes Gebäude mit flexibler Aufteilung. Hervorragende ÖV-Anbindung und erstklassige Lage. Geeignet als Büro, Showroom oder gemischte Nutzung.', description: 'Grosszügige Gewerbefläche an der Europaallee, Zürich Kreis 4, direkt beim Hauptbahnhof. Modernes Gebäude mit flexibler Aufteilung. Hervorragende ÖV-Anbindung und erstklassige Lage. Geeignet als Büro, Showroom oder gemischte Nutzung.',
hardFacts: { fitOut: 'FULL' },
units: [ units: [
{ id: 'unit-004-1', propertyId: 'prop-004', floorLevel: 2, areaSqm: 1150, available: true, rentPricePerSqm: 624 }, { id: 'unit-004-1', propertyId: 'prop-004', floorLevel: 2, areaSqm: 1150, available: true, rentPricePerSqm: 624 },
], ],
@@ -1193,6 +1201,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'LU-MW-015', propertyNumber: 'LU-MW-015',
description: 'Helle Bürofläche am Kasernenplatz Luzern, im Herzen der Innenstadt. Das 3. Obergeschoss bietet eine grosszügige, flexible Raumaufteilung mit natürlichem Licht von drei Seiten. ÖV-Verbindungen in unmittelbarer Nähe (Bahnhof Luzern, 5 Minuten zu Fuss). Ideal für professionelle Dienstleister.', description: 'Helle Bürofläche am Kasernenplatz Luzern, im Herzen der Innenstadt. Das 3. Obergeschoss bietet eine grosszügige, flexible Raumaufteilung mit natürlichem Licht von drei Seiten. ÖV-Verbindungen in unmittelbarer Nähe (Bahnhof Luzern, 5 Minuten zu Fuss). Ideal für professionelle Dienstleister.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-015-1', propertyId: 'prop-015', floorLevel: 3, areaSqm: 650, available: true, rentPricePerSqm: 456 }, { id: 'unit-015-1', propertyId: 'prop-015', floorLevel: 3, areaSqm: 650, available: true, rentPricePerSqm: 456 },
], ],
@@ -1236,6 +1245,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BL-MW-016', propertyNumber: 'BL-MW-016',
description: 'Grosszügige Lagerhalle an der Rheinfelderstrasse, direkt im Industriegebiet Muttenz. Ebenerdige Anlieferung mit breitem Tor, 35 Aussenparkplätze. Anbindung A2/A3 unter 5 Minuten. Ideal für Lagerung, Distribution und Leichtindustrie.', description: 'Grosszügige Lagerhalle an der Rheinfelderstrasse, direkt im Industriegebiet Muttenz. Ebenerdige Anlieferung mit breitem Tor, 35 Aussenparkplätze. Anbindung A2/A3 unter 5 Minuten. Ideal für Lagerung, Distribution und Leichtindustrie.',
hardFacts: { fitOut: 'SHELL' },
units: [ units: [
{ id: 'unit-016-1', propertyId: 'prop-016', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2200, available: true, rentPricePerSqm: 192 }, { id: 'unit-016-1', propertyId: 'prop-016', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2200, available: true, rentPricePerSqm: 192 },
], ],
@@ -1280,6 +1290,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-017', propertyNumber: 'ZH-EXT-017',
description: 'Erstklassige Ladenfläche an der Löwenstrasse im Zürcher Hauptbahnhof-Umfeld. EG, direkt an der Fussgängerzone mit sehr hoher Frequenz durch Pendler und Touristen. Schaufensterfront auf zwei Seiten.', description: 'Erstklassige Ladenfläche an der Löwenstrasse im Zürcher Hauptbahnhof-Umfeld. EG, direkt an der Fussgängerzone mit sehr hoher Frequenz durch Pendler und Touristen. Schaufensterfront auf zwei Seiten.',
hardFacts: { fitOut: 'FULL' },
units: [ units: [
{ id: 'unit-017-1', propertyId: 'prop-017', floorLevel: 0, areaSqm: 350, available: true, rentPricePerSqm: 1140 }, { id: 'unit-017-1', propertyId: 'prop-017', floorLevel: 0, areaSqm: 350, available: true, rentPricePerSqm: 1140 },
], ],
@@ -1323,6 +1334,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-018', propertyNumber: 'BE-EXT-018',
description: 'Praktische Bürofläche im 2. Obergeschoss, Breitenrainstrasse Bern. Grosszügige, lichtdurchflutete Räume in ruhigem Quartier nahe dem Stadtzentrum. Bahn- und Busanbindung in 8 Minuten Fussweg. Ideal für Büros, Beratungsfirmen oder Praxen.', description: 'Praktische Bürofläche im 2. Obergeschoss, Breitenrainstrasse Bern. Grosszügige, lichtdurchflutete Räume in ruhigem Quartier nahe dem Stadtzentrum. Bahn- und Busanbindung in 8 Minuten Fussweg. Ideal für Büros, Beratungsfirmen oder Praxen.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-018-1', propertyId: 'prop-018', floorLevel: 2, areaSqm: 780, available: true, rentPricePerSqm: 372 }, { id: 'unit-018-1', propertyId: 'prop-018', floorLevel: 2, areaSqm: 780, available: true, rentPricePerSqm: 372 },
], ],
@@ -1361,6 +1373,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BS-MW-019', propertyNumber: 'BS-MW-019',
description: 'Grosszügige Produktionshalle mit erhöhten Bodenlasten und Dreiphasenstrom im Industriequartier Kleinhüningen Basel. Direkte LKW-Zufahrt über die Voltastrasse. Ideal für Leichtindustrie, Montage oder Lagerhaltung.', description: 'Grosszügige Produktionshalle mit erhöhten Bodenlasten und Dreiphasenstrom im Industriequartier Kleinhüningen Basel. Direkte LKW-Zufahrt über die Voltastrasse. Ideal für Leichtindustrie, Montage oder Lagerhaltung.',
hardFacts: { fitOut: 'SHELL' },
units: [ units: [
{ id: 'unit-019-1', propertyId: 'prop-019', floorLevel: 0, unitLabel: 'Produktionshalle', areaSqm: 1900, available: true, rentPricePerSqm: 156 }, { id: 'unit-019-1', propertyId: 'prop-019', floorLevel: 0, unitLabel: 'Produktionshalle', areaSqm: 1900, available: true, rentPricePerSqm: 156 },
], ],
@@ -1404,6 +1417,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZG-MW-020', propertyNumber: 'ZG-MW-020',
description: 'Grosszügige Bürofläche im 4. Obergeschoss, Industriestrasse Zug. Ruhige Lage mit Panorama-Bergblick, direkter Autobahnanschluss A4. Ideal für Firmen, die von der Zuger Steuerpolitik profitieren möchten, ohne Premium-Innenstadtmieten zu zahlen.', description: 'Grosszügige Bürofläche im 4. Obergeschoss, Industriestrasse Zug. Ruhige Lage mit Panorama-Bergblick, direkter Autobahnanschluss A4. Ideal für Firmen, die von der Zuger Steuerpolitik profitieren möchten, ohne Premium-Innenstadtmieten zu zahlen.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-020-1', propertyId: 'prop-020', floorLevel: 4, areaSqm: 820, available: true, rentPricePerSqm: 528 }, { id: 'unit-020-1', propertyId: 'prop-020', floorLevel: 4, areaSqm: 820, available: true, rentPricePerSqm: 528 },
], ],
@@ -1448,6 +1462,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'GE-MW-021', propertyNumber: 'GE-MW-021',
description: 'Exklusive Verkaufsfläche im Erdgeschoss an der Rue du Rhône, Genf Zentrum. Eine der prestigeträchtigsten Einkaufsstrassen der Schweiz. Ideal für Luxusmarken, Juweliere oder hochwertige Dienstleister mit Anforderung an Prestige und Visibilität.', description: 'Exklusive Verkaufsfläche im Erdgeschoss an der Rue du Rhône, Genf Zentrum. Eine der prestigeträchtigsten Einkaufsstrassen der Schweiz. Ideal für Luxusmarken, Juweliere oder hochwertige Dienstleister mit Anforderung an Prestige und Visibilität.',
hardFacts: { fitOut: 'PREMIUM' },
units: [ units: [
{ id: 'unit-021-1', propertyId: 'prop-021', floorLevel: 0, areaSqm: 250, available: true, rentPricePerSqm: 1344 }, { id: 'unit-021-1', propertyId: 'prop-021', floorLevel: 0, areaSqm: 250, available: true, rentPricePerSqm: 1344 },
], ],
@@ -1491,6 +1506,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'SG-EXT-022', propertyNumber: 'SG-EXT-022',
description: 'Helle Bürofläche an der Marktgasse im Zentrum St. Gallens. Das 2. Obergeschoss bietet eine zusammenhängende, gut aufteilbare Fläche in historischem Stadtquartier. St. Gallen HB in 6 Minuten zu Fuss. Ideal für Kanzleien, Dienstleister oder regionale Niederlassungen.', description: 'Helle Bürofläche an der Marktgasse im Zentrum St. Gallens. Das 2. Obergeschoss bietet eine zusammenhängende, gut aufteilbare Fläche in historischem Stadtquartier. St. Gallen HB in 6 Minuten zu Fuss. Ideal für Kanzleien, Dienstleister oder regionale Niederlassungen.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-022-1', propertyId: 'prop-022', floorLevel: 2, areaSqm: 700, available: true, rentPricePerSqm: 336 }, { id: 'unit-022-1', propertyId: 'prop-022', floorLevel: 2, areaSqm: 700, available: true, rentPricePerSqm: 336 },
], ],
@@ -1694,6 +1710,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-031', propertyNumber: 'ZH-EXT-031',
description: 'Repräsentative Bürofläche im 3. Obergeschoss im modernen Hardturm-Areal Zürich-West. Die zusammenhängende Fläche von 780 m² ist offen gestaltet und kann flexibel unterteilt werden. Grosse Fensterflächen, Klimaanlage und ein Untergeschoss-Parkhaus sind vorhanden. Das Gebäude befindet sich in zentraler Lage mit hervorragender Anbindung an den öffentlichen Verkehr (Tram 4/13, Bahnhof Hardbrücke, 4 Minuten zu Fuss). Übergabe ab Oktober 2025 möglich.', description: 'Repräsentative Bürofläche im 3. Obergeschoss im modernen Hardturm-Areal Zürich-West. Die zusammenhängende Fläche von 780 m² ist offen gestaltet und kann flexibel unterteilt werden. Grosse Fensterflächen, Klimaanlage und ein Untergeschoss-Parkhaus sind vorhanden. Das Gebäude befindet sich in zentraler Lage mit hervorragender Anbindung an den öffentlichen Verkehr (Tram 4/13, Bahnhof Hardbrücke, 4 Minuten zu Fuss). Übergabe ab Oktober 2025 möglich.',
hardFacts: { fitOut: 'FULL' },
units: [ units: [
{ id: 'unit-031-1', propertyId: 'prop-031', floorLevel: 3, areaSqm: 780, available: true, rentPricePerSqm: 420 }, { id: 'unit-031-1', propertyId: 'prop-031', floorLevel: 3, areaSqm: 780, available: true, rentPricePerSqm: 420 },
], ],
@@ -1732,6 +1749,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-MW-032', propertyNumber: 'ZH-MW-032',
description: 'Stilvolles Büroloft im 5. Obergeschoss eines ehemaligen Industriegebäudes an der Pfingstweidstrasse, Kreis 5. Die offene Loftstruktur mit Sichtbetondecken und -wänden schafft ein inspirierendes Arbeitsumfeld. Raumhöhe ca. 3,5 m, Holzböden, individuelle Klimatisierung. Panoramablick über die Dächer Zürichs. Ideal für kreative Unternehmen und Tech-Firmen. Verfügbar ab November 2025.', description: 'Stilvolles Büroloft im 5. Obergeschoss eines ehemaligen Industriegebäudes an der Pfingstweidstrasse, Kreis 5. Die offene Loftstruktur mit Sichtbetondecken und -wänden schafft ein inspirierendes Arbeitsumfeld. Raumhöhe ca. 3,5 m, Holzböden, individuelle Klimatisierung. Panoramablick über die Dächer Zürichs. Ideal für kreative Unternehmen und Tech-Firmen. Verfügbar ab November 2025.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-032-1', propertyId: 'prop-032', floorLevel: 5, areaSqm: 720, available: true, rentPricePerSqm: 480 }, { id: 'unit-032-1', propertyId: 'prop-032', floorLevel: 5, areaSqm: 720, available: true, rentPricePerSqm: 480 },
], ],
@@ -1770,6 +1788,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-MW-033', propertyNumber: 'BE-MW-033',
description: 'Attraktive Retailfläche an der Marktgasse, direkt in der Berner Fussgängerzone. EG-Fläche mit hoher Laufkundschaft durch Tourismus und Pendler. Tram und Bus direkt vor dem Haus. Ideal für Fashion, Lifestyle oder Gastronomie-Konzepte.', description: 'Attraktive Retailfläche an der Marktgasse, direkt in der Berner Fussgängerzone. EG-Fläche mit hoher Laufkundschaft durch Tourismus und Pendler. Tram und Bus direkt vor dem Haus. Ideal für Fashion, Lifestyle oder Gastronomie-Konzepte.',
hardFacts: { fitOut: 'FULL' },
units: [ units: [
{ id: 'unit-033-1', propertyId: 'prop-033', floorLevel: 0, areaSqm: 260, available: true, rentPricePerSqm: 1440 }, { id: 'unit-033-1', propertyId: 'prop-033', floorLevel: 0, areaSqm: 260, available: true, rentPricePerSqm: 1440 },
], ],
@@ -1808,6 +1827,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-034', propertyNumber: 'BE-EXT-034',
description: 'Ansprechende Ladenfläche im trendigen Quartier Lorraine, Bern. EG mit Schaufensterfront, gut positioniert für Kreativwirtschaft und gehobene Kundschaft. Busanbindung in 6 Minuten zum Bahnhof Bern.', description: 'Ansprechende Ladenfläche im trendigen Quartier Lorraine, Bern. EG mit Schaufensterfront, gut positioniert für Kreativwirtschaft und gehobene Kundschaft. Busanbindung in 6 Minuten zum Bahnhof Bern.',
hardFacts: { fitOut: 'BASIC' },
units: [ units: [
{ id: 'unit-034-1', propertyId: 'prop-034', floorLevel: 0, areaSqm: 340, available: true, rentPricePerSqm: 1320 }, { id: 'unit-034-1', propertyId: 'prop-034', floorLevel: 0, areaSqm: 340, available: true, rentPricePerSqm: 1320 },
], ],
@@ -1872,6 +1892,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BS-EXT-036', propertyNumber: 'BS-EXT-036',
description: 'Freistehendes Lager-/Logistikgebäude direkt an der Klybeckstrasse, Basel Hafen. Ebenerdige Anlieferung, 38 Aussenparkplätze. Hervorragende Lage für Distribution in der Nordwestschweiz. Kran und Sprinkleranlage vorhanden.', description: 'Freistehendes Lager-/Logistikgebäude direkt an der Klybeckstrasse, Basel Hafen. Ebenerdige Anlieferung, 38 Aussenparkplätze. Hervorragende Lage für Distribution in der Nordwestschweiz. Kran und Sprinkleranlage vorhanden.',
hardFacts: { fitOut: 'SHELL' },
units: [ units: [
{ id: 'unit-036-1', propertyId: 'prop-036', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2600, available: true, rentPricePerSqm: 180 }, { id: 'unit-036-1', propertyId: 'prop-036', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2600, available: true, rentPricePerSqm: 180 },
], ],
@@ -1909,6 +1930,7 @@ export const mockProperties: Property[] = [
parkingSpots: 0, parkingSpots: 0,
publicTransportMinutes: 3, publicTransportMinutes: 3,
}, },
hardFacts: { fitOut: 'FULL' },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 4.0, ancillaryCosts: 4.0,
@@ -2486,7 +2508,7 @@ export const mockProperties: Property[] = [
parkingSpots: 5, parkingSpots: 5,
publicTransportMinutes: 6, publicTransportMinutes: 6,
}, },
hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8 }, hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8, mieterausbaubeitragPerSqm: 250 },
floorLevel: 2, floorLevel: 2,
contractDurationMonths: 84, contractDurationMonths: 84,
ancillaryCosts: 3.8, ancillaryCosts: 3.8,
@@ -3709,7 +3731,7 @@ export const mockProperties: Property[] = [
parkingSpots: 30, parkingSpots: 30,
publicTransportMinutes: 14, publicTransportMinutes: 14,
}, },
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 120, contractDurationMonths: 120,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
@@ -3761,7 +3783,7 @@ export const mockProperties: Property[] = [
parkingSpots: 35, parkingSpots: 35,
publicTransportMinutes: 12, publicTransportMinutes: 12,
}, },
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 120, contractDurationMonths: 120,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
@@ -3813,7 +3835,7 @@ export const mockProperties: Property[] = [
parkingSpots: 40, parkingSpots: 40,
publicTransportMinutes: 16, publicTransportMinutes: 16,
}, },
hardFacts: { floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 120, contractDurationMonths: 120,
ancillaryCosts: 2.2, ancillaryCosts: 2.2,
@@ -3866,7 +3888,7 @@ export const mockProperties: Property[] = [
parkingSpots: 50, parkingSpots: 50,
publicTransportMinutes: 18, publicTransportMinutes: 18,
}, },
hardFacts: { floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 2.0, ancillaryCosts: 2.0,
@@ -3913,7 +3935,7 @@ export const mockProperties: Property[] = [
parkingSpots: 38, parkingSpots: 38,
publicTransportMinutes: 14, publicTransportMinutes: 14,
}, },
hardFacts: { floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
@@ -3958,7 +3980,7 @@ export const mockProperties: Property[] = [
parkingSpots: 35, parkingSpots: 35,
publicTransportMinutes: 14, publicTransportMinutes: 14,
}, },
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 120, contractDurationMonths: 120,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
@@ -4266,7 +4288,7 @@ export const mockProperties: Property[] = [
parkingSpots: 12, parkingSpots: 12,
publicTransportMinutes: 12, publicTransportMinutes: 12,
}, },
hardFacts: { floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 2.8, ancillaryCosts: 2.8,
@@ -4318,7 +4340,7 @@ export const mockProperties: Property[] = [
parkingSpots: 18, parkingSpots: 18,
publicTransportMinutes: 15, publicTransportMinutes: 15,
}, },
hardFacts: { floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true }, hardFacts: { fitOut: 'SHELL', floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true },
floorLevel: 0, floorLevel: 0,
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
+4 -1
View File
@@ -38,6 +38,7 @@ export default function AISearch() {
const [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights) const [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights)
const [weightingKey, setWeightingKey] = useState(0) const [weightingKey, setWeightingKey] = useState(0)
const [needTitle, setNeedTitle] = useState('') const [needTitle, setNeedTitle] = useState('')
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const isManualTextRef = useRef(false) const isManualTextRef = useRef(false)
@@ -165,7 +166,7 @@ export default function AISearch() {
setStep(NeedBuilderStep.SAVING) setStep(NeedBuilderStep.SAVING)
const entries = Object.entries(parseResult.confidenceByField) const entries = Object.entries(parseResult.confidenceByField)
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0 const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE') const input = buildNeedInput({ ...editedCriteria, isAnonymous: isAnonymous || undefined }, weights, needTitle, conf, 'ACTIVE')
createNeedMutation.mutate(input, { createNeedMutation.mutate(input, {
onSuccess: (created) => { onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ['matches'] }) queryClient.invalidateQueries({ queryKey: ['matches'] })
@@ -253,7 +254,9 @@ export default function AISearch() {
needTitle={needTitle} needTitle={needTitle}
overallConfidence={overallConfidence} overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING} isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle} onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)} onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile} onSave={handleSaveProfile}
/> />
+29 -2
View File
@@ -7,8 +7,8 @@ import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore' import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence } from '../../lib/locationIntelligence' import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel } from '../../components/match-detail' import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData' import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
@@ -72,6 +72,9 @@ export default function MatchDetail() {
const cityIntel = city ? getCityIntelligence(city) : null const cityIntel = city ? getCityIntelligence(city) : null
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0] const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.` const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
@@ -111,6 +114,15 @@ export default function MatchDetail() {
{ label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '' }, { label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '' },
{ label: 'Verfügbar ab', value: property?.availabilityDate ?? '' }, { label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ label: 'Nutzungsart', value: property?.assetType ?? '' }, { label: 'Nutzungsart', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab} MAB` : base
})(),
}] : []),
] ]
return ( return (
@@ -152,6 +164,10 @@ export default function MatchDetail() {
keyFacts={keyFacts} keyFacts={keyFacts}
onCompare={handleCompare} onCompare={handleCompare}
onShortlist={handleShortlist} onShortlist={handleShortlist}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/> />
{/* ── Main content ── */} {/* ── Main content ── */}
@@ -197,6 +213,17 @@ export default function MatchDetail() {
})} })}
/> />
{/* ── Section C: Reale Jahresbelastung (alle fitOut-Werte) ── */}
{!isFuture && property?.hardFacts?.fitOut && (
<FitOutCostPanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
/>
)}
{/* ── Full analysis toggle + expanded panels ── */} {/* ── Full analysis toggle + expanded panels ── */}
<MatchDetailScoreBreakdown <MatchDetailScoreBreakdown
match={match} match={match}
+33 -1
View File
@@ -1,5 +1,5 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material' import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router' import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
@@ -161,6 +161,29 @@ export default function Results() {
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })} <strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography> </Typography>
)} )}
{activeNeed.searchRadius && (
<Typography variant="caption" color="text.secondary">
<strong>Radius:</strong> {activeNeed.searchRadius} km
</Typography>
)}
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
<Typography variant="caption" color="text.secondary">
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
</Typography>
)}
{activeNeed.requiredFitOut && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaustandard:</strong> min. {
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
}
</Typography>
)}
{activeNeed.fitOutBudgetMaxPerSqm && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && ( {activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')} <strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
@@ -168,6 +191,14 @@ export default function Results() {
)} )}
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{activeNeed.isAnonymous && (
<Chip
label="Anonyme Suche"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
/>
)}
<Button <Button
size="small" size="small"
variant="text" variant="text"
@@ -176,6 +207,7 @@ export default function Results() {
> >
Suche ändern Suche ändern
</Button> </Button>
</Box>
</Box> </Box>
</Card> </Card>
)} )}
+3
View File
@@ -83,6 +83,9 @@ export default function NewListing() {
fitOut={form.fitOut} onFitOutChange={form.setFitOut} fitOut={form.fitOut} onFitOutChange={form.setFitOut}
parking={form.parking} onParkingChange={form.setParking} parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight} ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
isFlexible={form.isFlexible} onIsFlexibleChange={form.setIsFlexible}
minLettableSqm={form.minLettableSqm} onMinLettableSqmChange={form.setMinLettableSqm}
/> />
<ImageUrlSection <ImageUrlSection
+15
View File
@@ -19,11 +19,15 @@ export function buildCreatePropertyInput(fields: {
ceilingHeight: string ceilingHeight: string
images: string[] images: string[]
floorPlanUrl: string floorPlanUrl: string
mieterausbaubeitrag?: string
isFlexible?: boolean
minLettableSqm?: string
}): CreatePropertyInput { }): CreatePropertyInput {
const { const {
assetType, street, houseNumber, postalCode, city, assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description, areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl, softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields } = fields
const sf = { const sf = {
@@ -41,6 +45,7 @@ export function buildCreatePropertyInput(fields: {
const hf = { const hf = {
floor: floor ? parseInt(floor) : undefined, floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined, fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined, parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined, ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
} }
@@ -76,5 +81,15 @@ export function buildCreatePropertyInput(fields: {
warnings: [], warnings: [],
}, },
status: 'ACTIVE', status: 'ACTIVE',
units: isFlexible ? [{
id: `unit-listing-${Date.now()}`,
propertyId: '',
floorLevel: hf.floor ?? 0,
areaSqm,
available: true,
rentPricePerSqm: rentPerSqm,
isFlexible: true,
minLettableSqm: minLettableSqm ? parseInt(minLettableSqm) : undefined,
}] : undefined,
} }
} }
+23 -1
View File
@@ -7,7 +7,7 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult'
export interface AIProvenance { export interface AIProvenance {
/** Which AI provider produced this response */ /** Which AI provider produced this response */
provider: 'openrouter' | 'mock' provider: 'openrouter' | 'mock' | 'backend'
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */ /** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
model: string model: string
/** ISO-8601 timestamp of generation */ /** ISO-8601 timestamp of generation */
@@ -157,6 +157,25 @@ export interface DataQualityInput {
warnings: string[] warnings: string[]
} }
// ── Fit-out advice ────────────────────────────────────────────────────────────
export interface FitOutAdviceInput {
fitOut: string
areaSqm: number
mabPerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
monthlyRentPerSqm: number
}
export interface FitOutAdvice {
recommendation: 'MIETERAUSBAU' | 'BKZ' | 'MAB_AMORTISATION'
headline: string
explanation: string
negotiationTip: string
estimatedNetInvestment: string
}
// ── Legacy types (kept for backward compatibility) ──────────────────────────── // ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult { export interface CriteriaExtractionResult {
@@ -198,6 +217,9 @@ export interface IAIService {
// Offer email (supply side) // Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// Fit-out investment advice (demand side)
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
// Legacy methods // Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
+671
View File
@@ -0,0 +1,671 @@
/**
* Backend AI Service PowerOn Proxy
*
* Routes all LLM calls through the PowerOn backend. The LLM provider API key
* is stored ONLY server-side and never reaches the browser bundle.
*
*
* PowerOn Backend Contract
*
* Endpoint: POST /api/ai/chat/completions
* Headers: Content-Type: application/json
* (session auth cookie handled by backend no API key here)
*
* Request body:
* {
* messages: { role: 'system' | 'user'; content: string }[]
* }
*
* Response (OpenAI-compatible):
* {
* choices: [{ message: { content: string } }]
* }
*
* The backend adds:
* - Authorization: Bearer <OPENROUTER_API_KEY> (server-side env var)
* - Model selection / routing
* - Rate limiting & audit logging
*
*
* Dev setup add to vite.config.ts:
* server: { proxy: { '/api': process.env.AI_BACKEND_URL ?? 'http://localhost:3001' } }
*
* Every method follows this contract:
* 1. HTTP error error log + MockAIService fallback
* 2. JSON parse fail warn + MockAIService fallback
* 3. Zod schema fail warn + MockAIService fallback
* 4. Success AI response, source: 'ai', validationPassed: true
*/
import type { CreateNeedInput } from '../../../domain/need'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type { AssetType } from '../../../domain/enums'
import type {
IAIService,
AIResponse,
AIProvenance,
DecisionBrief,
ComparisonSummary,
CriteriaExtractionResult,
OfferEmailPayload,
MatchExplanationInput,
MatchExplanation,
TradeOffInput,
TradeOffSummary,
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
import { aiTraceStore, provenanceToStatus } from '../tracing'
import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
TradeOffSummaryResponseSchema,
CompareSummaryResponseSchema,
DecisionBriefResponseSchema,
DataQualitySummaryResponseSchema,
MarketSignalClassificationResponseSchema,
OfferEmailResponseSchema,
validateAIResponse,
} from '../schemas'
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
import { buildTradeOffPrompt } from '../prompts/tradeOffPrompt'
import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
import { MockAIService } from '../mock/MockAIService'
// ── Config ────────────────────────────────────────────────────────────────────
/** Relative URL — resolved by Vite proxy in dev, by the same-origin backend in prod. */
const API_BASE = '/api/ai'
/**
* Placeholder recorded in traces. The actual model is backend-controlled;
* PowerOn may return it in a response extension field in future.
*/
const BACKEND_MODEL_PLACEHOLDER = 'backend-controlled'
const PROMPT_VERSION = 'v1.1'
const SCHEMA_VERSION = 'v1.0'
// ── Provenance ────────────────────────────────────────────────────────────────
function makeProvenance(
source: AIProvenance['source'],
fallbackUsed: boolean,
validationPassed: boolean,
extras: { fallbackReason?: string } = {},
): AIProvenance {
return {
provider: 'backend',
model: BACKEND_MODEL_PLACEHOLDER,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source,
fallbackUsed,
validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
}
}
// ── HTTP helper ───────────────────────────────────────────────────────────────
async function chat(system: string, user: string): Promise<string> {
const res = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// No Authorization header — the API key lives server-side only.
},
body: JSON.stringify({
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
})
if (!res.ok) {
const body = await res.text()
throw new AppError({
code: ServiceErrorCode.AI_GENERATION_FAILED,
// Truncate to avoid leaking full backend error detail to the console.
message: `Backend AI error ${res.status}: ${body.slice(0, 200)}`,
})
}
const json = await res.json() as { choices: Array<{ message: { content: string } }> }
return json.choices[0]?.message?.content ?? ''
}
// ── JSON extraction ───────────────────────────────────────────────────────────
function extractJSON<T>(raw: string): T | null {
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
try {
return JSON.parse(candidate) as T
} catch {
return null
}
}
// ── ParseNeed helpers ─────────────────────────────────────────────────────────
type RawNeedParseAI = {
assetType?: string | null
areaRange?: { min: number; max: number } | null
preferredLocations?: string[]
budgetRange?: { maxPerSqm: number; currency: string } | null
timing?: { earliestMoveIn: string; latestMoveIn?: string; flexibleTiming: boolean } | null
mustHaveCriteria?: string[]
missingFields?: string[]
assumptions?: string[]
}
function followUpForField(field: string): string {
const MAP: Record<string, string> = {
assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?',
areaRange: 'Welche Fläche benötigen Sie (minmax in m²)?',
preferredLocations: 'In welchen Städten oder Regionen suchen Sie?',
budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?',
timing: 'Wann möchten Sie spätestens einziehen?',
mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
}
return MAP[field] ?? `Können Sie "${field}" präzisieren?`
}
function defaultSuggestedWeights(): Record<string, number> {
return {
area: 0.25, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.05, accessibility: 0.05, expansionPotential: 0.02,
flexibility: 0.02, visibility: 0.02, footfall: 0.01, talentAccess: 0.01,
esg: 0.01, taxEnvironment: 0.01,
}
}
// ── Fallback wrapper ──────────────────────────────────────────────────────────
type FallbackFn<T> = () => Promise<AIResponse<T>>
async function withFallback<T>(
label: string,
fn: () => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> {
const startMs = Date.now()
const callId = crypto.randomUUID()
try {
const result = await fn()
const latencyMs = Date.now() - startMs
const prov = result.provenance
const provenance: AIProvenance = {
...prov,
traceId: callId,
latencyMs,
schemaVersion: SCHEMA_VERSION,
}
aiTraceStore.add({
id: callId,
method: label,
provider: prov.provider,
model: prov.model,
promptVersion: prov.promptVersion,
latencyMs,
fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
fallbackReason: prov.fallbackReason,
source: prov.source,
createdAt: prov.generatedAt,
inputSizeChars,
})
return { ...result, provenance }
} catch (err) {
console.error(`[BackendAIService] ${label} failed:`, err)
const result = await fallback()
const latencyMs = Date.now() - startMs
const errorType: AITraceErrorType =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: err instanceof TypeError
? 'network'
: 'unknown'
const responseValidationStatus: AITraceValidationStatus =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: 'network_error'
const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason,
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: callId,
method: label,
provider: 'backend',
model: BACKEND_MODEL_PLACEHOLDER,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus,
errorType,
fallbackReason,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance }
}
}
// ── Service ───────────────────────────────────────────────────────────────────
export const BackendAIService: IAIService = {
// ── parseNeed ───────────────────────────────────────────────────────────────
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>> {
return withFallback('parseNeed', async () => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(system, user)
const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
if (!ai) {
console.warn('[BackendAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const extractedCriteria: ParseNeedResult['extractedCriteria'] = {
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
areaRange: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations,
budgetRange: ai.budgetRange ?? undefined,
timing: ai.timing
? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
: undefined,
mustHaveCriteria: ai.mustHaveCriteria,
}
const missingFields = ai.missingFields ?? []
const confidenceByField: Record<string, number> = {}
Object.keys(extractedCriteria).forEach(k => {
confidenceByField[k] = extractedCriteria[k as keyof typeof extractedCriteria] != null ? 0.85 : 0
})
missingFields.forEach(f => { confidenceByField[f] = 0 })
const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
id: `fq-be-${i}`,
questionText: followUpForField(field),
targetField: field,
reason: `Feld "${field}" nicht im Text erkannt`,
importance: 'recommended' as const,
}))
return {
data: {
extractedCriteria,
confidenceByField,
missingFields,
assumptions: ai.assumptions ?? [],
suggestedWeights: defaultSuggestedWeights(),
followUpQuestionCandidates,
rawSummary: raw.substring(0, 500),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.parseNeed(input), input.length)
},
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async () => {
const missingFields = [
...(!criteria.assetType ? ['assetType'] : []),
...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
...(!criteria.budgetRange ? ['budgetRange'] : []),
...(!criteria.timing ? ['timing'] : []),
...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
]
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(system, user)
const json = extractJSON<unknown[]>(raw)
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
if (!ai?.length) {
console.warn('[BackendAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map((q, i) => ({
id: `fq-be-${i}`,
questionText: q.questionText,
targetField: q.targetField,
reason: q.reason ?? 'AI-generiert',
suggestedAnswerOptions: q.suggestedAnswerOptions,
importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
})),
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateFollowUpQuestions(criteria))
},
// ── generateMatchExplanation ────────────────────────────────────────────────
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
return withFallback('generateMatchExplanation', async () => {
const { system, user } = buildMatchExplanationPrompt(input)
const raw = await chat(system, user)
const summary = raw.trim()
if (!summary) {
console.warn('[BackendAIService] generateMatchExplanation: empty response — using mock fallback')
const fb = await MockAIService.generateMatchExplanation(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: 'empty_response' }) }
}
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return {
data: {
headline: `${scoreLabel} Match — ${input.propertyTitle} (${input.matchScore}/100)`,
summary,
keyReasons: [
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
],
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateMatchExplanation(input))
},
// ── summarizeTradeOffs ──────────────────────────────────────────────────────
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
return withFallback('summarizeTradeOffs', async () => {
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
if (!ai) {
console.warn('[BackendAIService] summarizeTradeOffs: invalid response — using mock fallback')
const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
headline: ai.headline,
items: ai.items.map(item => ({
concern: item.concern,
severity: item.severity,
mitigation: item.mitigation,
})),
overallRisk: ai.overallRisk,
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.summarizeTradeOffs(tradeoffs))
},
// ── summarizeComparison ─────────────────────────────────────────────────────
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
return withFallback('summarizeComparison', async () => {
type ItemWithProp = UnifiedMatchResult & {
property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
}
const properties = (items as ItemWithProp[])
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
.map(i => ({
title: i.property?.title ?? `Match ${i.matchScore}`,
matchScore: i.matchScore,
city: i.property?.location?.city ?? '',
rentPerSqm: i.property?.rentPricePerSqm ?? 0,
positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
}))
const { system, user } = buildCompareSummaryPrompt({ properties })
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
if (!ai) {
console.warn('[BackendAIService] summarizeComparison: invalid response — using mock fallback')
const fb = await MockAIService.summarizeComparison(items)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.summarizeComparison(items)
return {
data: {
...mock.data,
overallAssessment: ai.overallAssessment,
recommendation: ai.recommendation ?? mock.data.recommendation,
},
provenance: makeProvenance('hybrid', false, true),
}
}, () => MockAIService.summarizeComparison(items))
},
// ── generateDecisionBrief ───────────────────────────────────────────────────
generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
return withFallback('generateDecisionBrief', async () => {
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
if (!ai) {
console.warn('[BackendAIService] generateDecisionBrief: invalid response — using mock fallback')
const fb = await MockAIService.generateDecisionBrief(shortlistId)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.generateDecisionBrief(shortlistId)
return {
data: {
...mock.data,
summary: ai.summary,
sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
},
provenance: makeProvenance('hybrid', false, true),
}
}, () => MockAIService.generateDecisionBrief(shortlistId))
},
// ── generateDataQualitySummary ──────────────────────────────────────────────
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
return withFallback('generateDataQualitySummary', async () => {
const { system, user } = buildDataQualityPrompt(propertyId, quality)
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
if (!ai) {
console.warn('[BackendAIService] generateDataQualitySummary: invalid response — using mock fallback')
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
overallAssessment: ai.overallAssessment,
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
recommendation: ai.recommendation,
confidence: ai.confidence,
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateDataQualitySummary(propertyId, quality))
},
// ── classifyMarketSignal ────────────────────────────────────────────────────
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
return withFallback('classifyMarketSignal', async () => {
const { system, user } = buildMarketSignalPrompt(signalText)
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json
? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
: null
if (!ai) {
console.warn('[BackendAIService] classifyMarketSignal: invalid response — using mock fallback')
const fb = await MockAIService.classifyMarketSignal(signalText)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
signalType: ai.signalType,
probability: ai.probability,
timeHorizonMonths: ai.timeHorizonMonths ?? null,
areaSqmEstimate: ai.areaSqmEstimate ?? null,
credibility: ai.credibility,
reasoning: ai.reasoning,
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
},
// ── generateOfferEmail ──────────────────────────────────────────────────────
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
return withFallback('generateOfferEmail', async () => {
const propertyList = payload.properties
.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`)
.join('\n')
const system = `Du bist Immobilienmakler bei Wincasa AG. Erstelle eine professionelle, knappe Angebotsmail auf Deutsch. Antworte als JSON: { "subject": "...", "body": "..." }`
const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
const raw = await chat(system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
if (!ai) {
console.warn('[BackendAIService] generateOfferEmail: invalid response — using mock fallback')
const fb = await MockAIService.generateOfferEmail(payload)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: { subject: ai.subject, body: ai.body },
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateOfferEmail(payload))
},
// ── generateFitOutAdvice ────────────────────────────────────────────────────
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>> {
return withFallback('generateFitOutAdvice', async () => {
const FIT_LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const system = `Du bist Schweizer Gewerbeimmobilien-Experte. Bewerte die Ausbausituation und empfiehl die beste Verhandlungsoption.
Verfügbare Optionen: MIETERAUSBAU (Mieter zahlt alles), BKZ (Vermieter zahlt Einmalpauschale), MAB_AMORTISATION (MAB über Miete amortisiert).
Antworte als JSON:
{
"recommendation": "MIETERAUSBAU" | "BKZ" | "MAB_AMORTISATION",
"headline": "kurze Empfehlung (max 80 Zeichen)",
"explanation": "2-3 Sätze Begründung auf Deutsch",
"negotiationTip": "konkreter Verhandlungstipp auf Deutsch",
"estimatedNetInvestment": "CHF-Betrag als String"
}`
const user = `Übergabezustand: ${FIT_LABELS[input.fitOut] ?? input.fitOut}
Fläche: ${input.areaSqm} m²
MAB des Vermieters: CHF ${input.mabPerSqm}/m²
Monatliche Miete: CHF ${input.monthlyRentPerSqm}/m²${input.tenantBudgetPerSqm ? `\nEigenes Ausbaubudget: CHF ${input.tenantBudgetPerSqm}/m²` : ''}${input.requiredFitOut ? `\nGewünschter Zustand: ${FIT_LABELS[input.requiredFitOut] ?? input.requiredFitOut}` : ''}
Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
const raw = await chat(system, user)
const json = extractJSON<FitOutAdvice>(raw)
if (!json || !json.recommendation || !json.headline) {
console.warn('[BackendAIService] generateFitOutAdvice: invalid response — using mock fallback')
const fb = await MockAIService.generateFitOutAdvice(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
recommendation: json.recommendation,
headline: json.headline,
explanation: json.explanation ?? '',
negotiationTip: json.negotiationTip ?? '',
estimatedNetInvestment: json.estimatedNetInvestment ?? '',
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateFitOutAdvice(input))
},
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async () => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(system, user)
const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
if (!ai) {
console.warn('[BackendAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
extractedCriteria: {
assetType: ai.assetType as AssetType | undefined ?? undefined,
requiredArea: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations ?? [],
budgetRange: ai.budgetRange ?? undefined,
},
confidence: 0.80,
missingFields: ai.missingFields ?? [],
assumptions: ai.assumptions ?? [],
followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
},
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.extractCriteria(input))
},
// ── Legacy: generateFollowUp ────────────────────────────────────────────────
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
return withFallback('generateFollowUp', async () => {
const missingFields = [
...(!partialNeed.assetType ? ['assetType'] : []),
...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []),
...(!partialNeed.timing ? ['timing'] : []),
...(!partialNeed.budgetRange ? ['budgetRange'] : []),
]
if (!missingFields.length) {
return { data: [], provenance: makeProvenance('ai', false, true) }
}
const { system, user } = buildFollowUpQuestionsPrompt({
criteria: partialNeed as ParsedNeedCriteria,
missingFields,
})
const raw = await chat(system, user)
const json = extractJSON<unknown[]>(raw)
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
if (!ai?.length) {
console.warn('[BackendAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
provenance: makeProvenance('ai', false, true),
}
}, () => MockAIService.generateFollowUp(partialNeed))
},
}
+28 -25
View File
@@ -1,44 +1,47 @@
/** /**
* AI Service Factory * AI Service Factory
* *
* Provider selection (priority order): * Provider selection via VITE_AI_PROVIDER:
* 1. VITE_AI_PROVIDER=openrouter OpenRouterAIService (requires VITE_OPENROUTER_API_KEY)
* 2. VITE_AI_PROVIDER=mock MockAIService (deterministic, no API key required)
* 3. VITE_USE_REAL_AI=true OpenRouterAIService (legacy flag, requires VITE_OPENROUTER_API_KEY)
* 4. (default) MockAIService
* *
* If VITE_AI_PROVIDER=openrouter but VITE_OPENROUTER_API_KEY is missing, the factory * backend (default) BackendAIService
* logs a warning and falls back to MockAIService never silently fails. * Calls POST /api/ai/chat/completions on the PowerOn backend.
* The LLM API key is stored server-side only not in this bundle.
* In development: configure a Vite proxy (see vite.config.ts).
* *
* Optional: VITE_OPENROUTER_MODEL controls which model OpenRouter uses. * mock MockAIService
* Default: anthropic/claude-3-5-haiku * Deterministic responses, no network calls.
* Use for local dev without a backend, or in CI.
*
* REMOVED: VITE_OPENROUTER_API_KEY and VITE_OPENROUTER_MODEL.
* The OpenRouter key is now a server-side secret in PowerOn.
*/ */
import { MockAIService } from './mock/MockAIService' import { MockAIService } from './mock/MockAIService'
import { OpenRouterAIService } from './openrouter/OpenRouterAIService' import { BackendAIService } from './backend/BackendAIService'
import type { IAIService } from './IAIService' import type { IAIService } from './IAIService'
function resolveProvider(): IAIService { function resolveProvider(): IAIService {
const provider = import.meta.env.VITE_AI_PROVIDER as string | undefined const provider = import.meta.env.VITE_AI_PROVIDER as string | undefined
const legacyRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
const wantsOpenRouter = provider === 'openrouter' || (legacyRealAI && !provider) if (provider === 'mock') {
return MockAIService
if (wantsOpenRouter) {
if (!apiKey) {
console.warn(
'[aiService] OpenRouter selected but VITE_OPENROUTER_API_KEY is missing — falling back to MockAIService.',
'Set VITE_AI_PROVIDER=mock to suppress this warning.',
)
return MockAIService
}
return OpenRouterAIService
} }
return MockAIService if (provider === 'openrouter') {
console.warn(
'[aiService] VITE_AI_PROVIDER=openrouter is no longer supported. ' +
'Direct OpenRouter calls have been removed from the frontend. ' +
'Using BackendAIService (POST /api/ai/chat/completions) instead. ' +
'Set VITE_AI_PROVIDER=backend or remove the variable to suppress this warning.',
)
}
// Default: backend proxy. Falls back to mock automatically on network/HTTP errors.
return BackendAIService
} }
export const aiService: IAIService = resolveProvider() export const aiService: IAIService = resolveProvider()
export { MockAIService, OpenRouterAIService } export { MockAIService, BackendAIService }
// Compatibility alias for any code that still imports OpenRouterAIService by name.
export { OpenRouterAIService } from './openrouter/OpenRouterAIService'
export type { IAIService } export type { IAIService }
+44
View File
@@ -10,6 +10,8 @@ import type {
TradeOffSummary, TradeOffSummary,
DataQualityInput, DataQualityInput,
MarketSignalClassification, MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
} from '../IAIService' } from '../IAIService'
import { mockProvenance } from '../IAIService' import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing' import { aiTraceStore } from '../tracing'
@@ -294,6 +296,48 @@ export const MockAIService: IAIService = {
} }
}), }),
generateFitOutAdvice: (input: FitOutAdviceInput) =>
traceMock('generateFitOutAdvice', async () => {
await delay(SIMULATED_DELAY.medium)
const mab = input.mabPerSqm
const fitOut = input.fitOut
let recommendation: FitOutAdvice['recommendation']
let headline: string
let explanation: string
let negotiationTip: string
if (fitOut === 'SHELL') {
if (mab >= 300) {
recommendation = 'MAB_AMORTISATION'
headline = 'MAB-Amortisation empfohlen — Vermieter trägt Grossteil der Ausbaukosten'
explanation = `Mit CHF ${mab}/m² MAB übernimmt der Vermieter einen erheblichen Teil der Ausbauinvestition. Die verbleibende Nettoinvestition wird über die Vertragslaufzeit amortisiert. Für ${input.areaSqm.toLocaleString('de-CH')} m² Rohbaufläche ist dies die kosteneffizienteste Lösung.`
negotiationTip = 'Verhandeln Sie eine höhere MAB-Rate gegen eine längere Mietvertragslaufzeit (min. 5 Jahre).'
} else {
recommendation = 'BKZ'
headline = 'Baukostenzuschuss (BKZ) verhandeln — Vermieter zahlt Ausbaupauschale'
explanation = `Bei SHELL-Übergabe ohne wesentlichem MAB ist ein Baukostenzuschuss (BKZ) die effektivste Option. Der Vermieter zahlt einen einmaligen Betrag, den Sie für den Innenausbau nutzen. Typisch sind CHF 200400/m² als BKZ.`
negotiationTip = `Fordern Sie CHF ${Math.round(300 * input.areaSqm / 1000) * 1000}. als BKZ-Pauschale. Reichen Sie Ausbauofferten von 2 Generalunternehmern vor der Unterzeichnung ein.`
}
} else {
recommendation = 'MIETERAUSBAU'
headline = 'Mieterausbau auf eigene Rechnung — geringe Restinvestition'
explanation = `${input.fitOut === 'BASIC' ? 'Basisausbau' : 'Vollausbau'} erfordert nur noch Anpassungen nach Ihren Bedürfnissen. Die Investition ist überschaubar und amortisiert sich bei einer Mietdauer von 3+ Jahren.`
negotiationTip = 'Lassen Sie eine Ausbauklausel im Mietvertrag festhalten: Entfernung von Mieterausbauten bei Auszug nur auf explizite Anforderung des Vermieters.'
}
const grossMin = (fitOut === 'SHELL' ? 800 : 400) - mab
const grossMax = (fitOut === 'SHELL' ? 1500 : 800) - mab
const netMin = Math.max(0, grossMin)
const netMax = Math.max(0, grossMax)
const estimatedNetInvestment = netMax <= 0
? 'Vollständig durch MAB gedeckt'
: `CHF ${Math.round(netMin * input.areaSqm / 1000) * 1000}${Math.round(netMax * input.areaSqm / 1000) * 1000}.`
const data: FitOutAdvice = { recommendation, headline, explanation, negotiationTip, estimatedNetInvestment }
return { data, provenance: mockProvenance() }
}),
// Legacy methods // Legacy methods
extractCriteria: (_input: string) => extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({ traceMock('extractCriteria', async () => ({
+9
View File
@@ -198,6 +198,13 @@ export function mockParseNeed(input: string): ParseNeedResult {
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC' : lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined : undefined
// Search radius: "innerhalb von 30 km", "30km Umkreis", "im Umkreis von 50 km", "radius 20km"
const radiusMatch =
input.match(/(?:innerhalb\s+(?:von\s+)?|im\s+umkreis\s+(?:von\s+)?|radius\s+(?:von\s+)?)(\d+)\s*km/i) ??
input.match(/(\d+)\s*km\s*(?:umkreis|radius|entfernung)/i) ??
input.match(/(\d+)\s*km/i)
const searchRadius = radiusMatch ? Math.min(100, Math.max(1, parseInt(radiusMatch[1]))) : undefined
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start // Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc. // Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i) const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
@@ -244,6 +251,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
budgetRange: budgetConfidence, budgetRange: budgetConfidence,
timing: timingConfidence, timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10, mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
searchRadius: searchRadius ? 0.90 : 0.20,
prestigeImportance: prestigeImportance ? 0.80 : 0.20, prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30, parkingNeed: parkingNeed ? 0.90 : 0.30,
} }
@@ -362,6 +370,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
requiredFitOut: fitOutStr, requiredFitOut: fitOutStr,
minCeilingHeightM, minCeilingHeightM,
minContractDurationMonths, minContractDurationMonths,
searchRadius,
notes, notes,
}, },
confidenceByField, confidenceByField,
@@ -1,650 +1,12 @@
/** /**
* OpenRouter AI Service * @deprecated Direct OpenRouter calls from the frontend have been removed.
* *
* Activation: * All LLM requests now go through the PowerOn backend proxy at /api/ai/chat/completions
* VITE_AI_PROVIDER=openrouter * so that the API key never appears in the browser bundle.
* VITE_OPENROUTER_API_KEY=<your-key>
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
* *
* Every method follows this contract: * This file is kept as a compatibility re-export so that any existing imports
* 1. No API key warn + MockAIService fallback (fallbackUsed: true) * of `OpenRouterAIService` continue to compile without changes.
* 2. HTTP error error log + MockAIService fallback
* 3. JSON parse fail warn + MockAIService fallback
* 4. Zod schema fail warn + MockAIService fallback NEW
* 5. Success (full AI) AI response, source: 'ai', validationPassed: true
* 6. Hybrid source: 'hybrid', documented per-method
* *
* No invalid data ever reaches the UI. * Implementation moved to: src/services/ai/backend/BackendAIService.ts
*/ */
import type { CreateNeedInput } from '../../../domain/need' export { BackendAIService as OpenRouterAIService } from '../backend/BackendAIService'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type { AssetType } from '../../../domain/enums'
import type {
IAIService,
AIResponse,
AIProvenance,
DecisionBrief,
ComparisonSummary,
CriteriaExtractionResult,
OfferEmailPayload,
MatchExplanationInput,
MatchExplanation,
TradeOffInput,
TradeOffSummary,
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
import { aiTraceStore, provenanceToStatus } from '../tracing'
import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
TradeOffSummaryResponseSchema,
CompareSummaryResponseSchema,
DecisionBriefResponseSchema,
DataQualitySummaryResponseSchema,
MarketSignalClassificationResponseSchema,
OfferEmailResponseSchema,
validateAIResponse,
} from '../schemas'
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
import { buildTradeOffPrompt } from '../prompts/tradeOffPrompt'
import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
import { MockAIService } from '../mock/MockAIService'
// ── Config ────────────────────────────────────────────────────────────────────
const API_BASE = 'https://openrouter.ai/api/v1'
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
const PROMPT_VERSION = 'v1.1'
const SCHEMA_VERSION = 'v1.0'
interface OpenRouterConfig {
apiKey: string
model: string
}
function getConfig(): OpenRouterConfig | null {
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
if (!apiKey) return null
return {
apiKey,
model: (import.meta.env.VITE_OPENROUTER_MODEL as string | undefined) ?? DEFAULT_MODEL,
}
}
function makeProvenance(
config: OpenRouterConfig,
source: AIProvenance['source'],
fallbackUsed: boolean,
validationPassed: boolean,
extras: { fallbackReason?: string } = {},
): AIProvenance {
return {
provider: 'openrouter',
model: config.model,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source,
fallbackUsed,
validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
}
}
// ── HTTP helper ───────────────────────────────────────────────────────────────
async function chat(config: OpenRouterConfig, system: string, user: string): Promise<string> {
const res = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
})
if (!res.ok) {
const body = await res.text()
throw new AppError({
code: ServiceErrorCode.AI_GENERATION_FAILED,
message: `OpenRouter error ${res.status}: ${body}`,
})
}
const json = await res.json() as { choices: Array<{ message: { content: string } }> }
return json.choices[0]?.message?.content ?? ''
}
// ── JSON extraction ───────────────────────────────────────────────────────────
function extractJSON<T>(raw: string): T | null {
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
try {
return JSON.parse(candidate) as T
} catch {
return null
}
}
// ── ParseNeed helpers ─────────────────────────────────────────────────────────
type RawNeedParseAI = {
assetType?: string | null
areaRange?: { min: number; max: number } | null
preferredLocations?: string[]
budgetRange?: { maxPerSqm: number; currency: string } | null
timing?: { earliestMoveIn: string; latestMoveIn?: string; flexibleTiming: boolean } | null
mustHaveCriteria?: string[]
missingFields?: string[]
assumptions?: string[]
}
function followUpForField(field: string): string {
const MAP: Record<string, string> = {
assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?',
areaRange: 'Welche Fläche benötigen Sie (minmax in m²)?',
preferredLocations: 'In welchen Städten oder Regionen suchen Sie?',
budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?',
timing: 'Wann möchten Sie spätestens einziehen?',
mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
}
return MAP[field] ?? `Können Sie "${field}" präzisieren?`
}
function defaultSuggestedWeights(): Record<string, number> {
return {
area: 0.25, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.05, accessibility: 0.05, expansionPotential: 0.02,
flexibility: 0.02, visibility: 0.02, footfall: 0.01, talentAccess: 0.01,
esg: 0.01, taxEnvironment: 0.01,
}
}
// ── Fallback wrapper ──────────────────────────────────────────────────────────
type FallbackFn<T> = () => Promise<AIResponse<T>>
async function withFallback<T>(
label: string,
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> {
const config = getConfig()
const startMs = Date.now()
const callId = crypto.randomUUID()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback()
const latencyMs = Date.now() - startMs
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason: 'no_api_key',
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: callId,
method: label,
provider: 'openrouter',
model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus: 'fallback',
errorType: 'no_api_key',
fallbackReason: 'no_api_key',
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance }
}
try {
const result = await fn(config)
const latencyMs = Date.now() - startMs
const prov = result.provenance
const provenance: AIProvenance = {
...prov,
traceId: callId,
latencyMs,
schemaVersion: SCHEMA_VERSION,
}
aiTraceStore.add({
id: callId,
method: label,
provider: prov.provider,
model: prov.model,
promptVersion: prov.promptVersion,
latencyMs,
fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
fallbackReason: prov.fallbackReason,
source: prov.source,
createdAt: prov.generatedAt,
inputSizeChars,
})
return { ...result, provenance }
} catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err)
const result = await fallback()
const latencyMs = Date.now() - startMs
const errorType: AITraceErrorType =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: err instanceof TypeError
? 'network'
: 'unknown'
const responseValidationStatus: AITraceValidationStatus =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: 'network_error'
const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason,
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: callId,
method: label,
provider: 'openrouter',
model: config.model,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus,
errorType,
fallbackReason,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance }
}
}
// ── Service ───────────────────────────────────────────────────────────────────
export const OpenRouterAIService: IAIService = {
// ── parseNeed ───────────────────────────────────────────────────────────────
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>> {
return withFallback('parseNeed', async (config) => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(config, system, user)
const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
if (!ai) {
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const extractedCriteria: ParsedNeedCriteria = {
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
areaRange: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations,
budgetRange: ai.budgetRange ?? undefined,
timing: ai.timing
? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
: undefined,
mustHaveCriteria: ai.mustHaveCriteria,
}
const missingFields = ai.missingFields ?? []
const confidenceByField: Record<string, number> = {}
Object.keys(extractedCriteria).forEach(k => {
confidenceByField[k] = extractedCriteria[k as keyof ParsedNeedCriteria] != null ? 0.85 : 0
})
missingFields.forEach(f => { confidenceByField[f] = 0 })
const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
id: `fq-or-${i}`,
questionText: followUpForField(field),
targetField: field,
reason: `Feld "${field}" nicht im Text erkannt`,
importance: 'recommended' as const,
}))
return {
data: {
extractedCriteria,
confidenceByField,
missingFields,
assumptions: ai.assumptions ?? [],
suggestedWeights: defaultSuggestedWeights(),
followUpQuestionCandidates,
rawSummary: raw.substring(0, 500),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.parseNeed(input), input.length)
},
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = [
...(!criteria.assetType ? ['assetType'] : []),
...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
...(!criteria.budgetRange ? ['budgetRange'] : []),
...(!criteria.timing ? ['timing'] : []),
...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
]
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user)
const json = extractJSON<unknown[]>(raw)
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map((q, i) => ({
id: `fq-or-${i}`,
questionText: q.questionText,
targetField: q.targetField,
reason: q.reason ?? 'AI-generiert',
suggestedAnswerOptions: q.suggestedAnswerOptions,
importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
})),
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateFollowUpQuestions(criteria))
},
// ── generateMatchExplanation ────────────────────────────────────────────────
// Plain-text response — no JSON schema to validate, but non-empty check enforced.
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
return withFallback('generateMatchExplanation', async (config) => {
const { system, user } = buildMatchExplanationPrompt(input)
const raw = await chat(config, system, user)
const summary = raw.trim()
if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
const fb = await MockAIService.generateMatchExplanation(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: 'empty_response' }) }
}
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return {
data: {
headline: `${scoreLabel} Match — ${input.propertyTitle} (${input.matchScore}/100)`,
summary,
keyReasons: [
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
],
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateMatchExplanation(input))
},
// ── summarizeTradeOffs ──────────────────────────────────────────────────────
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
return withFallback('summarizeTradeOffs', async (config) => {
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
if (!ai) {
console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback')
const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
headline: ai.headline,
items: ai.items.map(item => ({
concern: item.concern,
severity: item.severity,
mitigation: item.mitigation,
})),
overallRisk: ai.overallRisk,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.summarizeTradeOffs(tradeoffs))
},
// ── summarizeComparison ─────────────────────────────────────────────────────
// Hybrid: AI provides narrative text; mock provides structural per-property data.
// source: 'hybrid' — both are labeled in provenance.
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
return withFallback('summarizeComparison', async (config) => {
type ItemWithProp = UnifiedMatchResult & {
property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
}
const properties = (items as ItemWithProp[])
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
.map(i => ({
title: i.property?.title ?? `Match ${i.matchScore}`,
matchScore: i.matchScore,
city: i.property?.location?.city ?? '',
rentPerSqm: i.property?.rentPricePerSqm ?? 0,
positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
}))
const { system, user } = buildCompareSummaryPrompt({ properties })
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
if (!ai) {
console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
const fb = await MockAIService.summarizeComparison(items)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.summarizeComparison(items)
return {
data: {
...mock.data,
overallAssessment: ai.overallAssessment,
recommendation: ai.recommendation ?? mock.data.recommendation,
},
provenance: makeProvenance(config, 'hybrid', false, true),
}
}, () => MockAIService.summarizeComparison(items))
},
// ── generateDecisionBrief ───────────────────────────────────────────────────
// Hybrid: AI generates narrative summary + sections; mock fills structural metadata.
generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
return withFallback('generateDecisionBrief', async (config) => {
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
const fb = await MockAIService.generateDecisionBrief(shortlistId)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.generateDecisionBrief(shortlistId)
return {
data: {
...mock.data,
summary: ai.summary,
sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
},
provenance: makeProvenance(config, 'hybrid', false, true),
}
}, () => MockAIService.generateDecisionBrief(shortlistId))
},
// ── generateDataQualitySummary ──────────────────────────────────────────────
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
return withFallback('generateDataQualitySummary', async (config) => {
const { system, user } = buildDataQualityPrompt(propertyId, quality)
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
overallAssessment: ai.overallAssessment,
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
recommendation: ai.recommendation,
confidence: ai.confidence,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateDataQualitySummary(propertyId, quality))
},
// ── classifyMarketSignal ────────────────────────────────────────────────────
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
return withFallback('classifyMarketSignal', async (config) => {
const { system, user } = buildMarketSignalPrompt(signalText)
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json
? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
: null
if (!ai) {
console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback')
const fb = await MockAIService.classifyMarketSignal(signalText)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
signalType: ai.signalType,
probability: ai.probability,
timeHorizonMonths: ai.timeHorizonMonths ?? null,
areaSqmEstimate: ai.areaSqmEstimate ?? null,
credibility: ai.credibility,
reasoning: ai.reasoning,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
},
// ── generateOfferEmail ──────────────────────────────────────────────────────
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
return withFallback('generateOfferEmail', async (config) => {
const propertyList = payload.properties
.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`)
.join('\n')
const system = `Du bist Immobilienmakler bei Wincasa AG. Erstelle eine professionelle, knappe Angebotsmail auf Deutsch. Antworte als JSON: { "subject": "...", "body": "..." }`
const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
const fb = await MockAIService.generateOfferEmail(payload)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: { subject: ai.subject, body: ai.body },
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateOfferEmail(payload))
},
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async (config) => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(config, system, user)
const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
if (!ai) {
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
extractedCriteria: {
assetType: ai.assetType as AssetType | undefined ?? undefined,
requiredArea: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations ?? [],
budgetRange: ai.budgetRange ?? undefined,
},
confidence: 0.80,
missingFields: ai.missingFields ?? [],
assumptions: ai.assumptions ?? [],
followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.extractCriteria(input))
},
// ── Legacy: generateFollowUp ────────────────────────────────────────────────
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
return withFallback('generateFollowUp', async (config) => {
const missingFields = [
...(!partialNeed.assetType ? ['assetType'] : []),
...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []),
...(!partialNeed.timing ? ['timing'] : []),
...(!partialNeed.budgetRange ? ['budgetRange'] : []),
]
if (!missingFields.length) {
return { data: [], provenance: makeProvenance(config, 'ai', false, true) }
}
const { system, user } = buildFollowUpQuestionsPrompt({
criteria: partialNeed as ParsedNeedCriteria,
missingFields,
})
const raw = await chat(config, system, user)
const json = extractJSON<unknown[]>(raw)
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateFollowUp(partialNeed))
},
}
@@ -16,6 +16,10 @@ export function generateSummary(c: ParsedNeedCriteria): string {
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`) if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`) if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`) if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
if (c.searchRadius) parts.push(`Radius ${c.searchRadius} km`)
if (c.isAnonymous) parts.push('Anonyme Suche')
if (c.requiresDivisibility && c.minDivisibleUnit) parts.push(`Teilbar ab ${c.minDivisibleUnit}`)
if (c.fitOutBudgetMaxPerSqm) parts.push(`Ausbaubudget max. CHF ${c.fitOutBudgetMaxPerSqm}/m²`)
return parts.join(', ') return parts.join(', ')
} }
@@ -50,6 +54,11 @@ export function buildNeedInput(
requireBarrierFree: criteria.requireBarrierFree, requireBarrierFree: criteria.requireBarrierFree,
minCeilingHeightM: criteria.minCeilingHeightM, minCeilingHeightM: criteria.minCeilingHeightM,
minContractDurationMonths: criteria.minContractDurationMonths, minContractDurationMonths: criteria.minContractDurationMonths,
searchRadius: criteria.searchRadius,
isAnonymous: criteria.isAnonymous,
requiresDivisibility: criteria.requiresDivisibility,
minDivisibleUnit: criteria.minDivisibleUnit,
fitOutBudgetMaxPerSqm: criteria.fitOutBudgetMaxPerSqm,
notes: criteria.notes, notes: criteria.notes,
extractedFromText: undefined, extractedFromText: undefined,
} }
+15
View File
@@ -0,0 +1,15 @@
import { MockupUnitProvider } from '../provider/MockupUnitProvider'
import type { PropertyUnit } from '../domain/property'
import { throwServiceError } from './errors'
import type { ItemResponse } from './types'
export const unitService = {
async update(unitId: string, data: Partial<PropertyUnit>): Promise<ItemResponse<PropertyUnit>> {
try {
const unit = await MockupUnitProvider.update(unitId, data)
return { data: unit }
} catch (err) {
throwServiceError('unitService.update', err)
}
},
}
+14
View File
@@ -7,4 +7,18 @@ export default defineConfig({
react(), react(),
tailwindcss(), tailwindcss(),
], ],
server: {
proxy: {
// In dev: forward /api/* to the local PowerOn backend.
// Set AI_BACKEND_URL in your shell or .env.local (no VITE_ prefix — never bundled).
// Example: AI_BACKEND_URL=http://localhost:3001
//
// In production: the same-origin backend serves /api/* directly.
// No proxy needed; the relative URL /api/ai/chat/completions resolves correctly.
'/api': {
target: process.env['AI_BACKEND_URL'] ?? 'http://localhost:3001',
changeOrigin: true,
},
},
},
}) })