Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d3071010 | |||
| 5a1f412ce3 | |||
| b2530f9e20 |
Binary file not shown.
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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} m²`}
|
||||||
|
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>
|
||||||
|
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||||
</Typography>
|
</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')} m²`}
|
||||||
|
/>
|
||||||
|
{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')} m²`} />}
|
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />}
|
||||||
|
{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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={{
|
|
||||||
height,
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0,
|
|
||||||
// Ensure leaflet controls appear above MUI elements
|
|
||||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||||
'& .leaflet-control': { zIndex: 1 },
|
'& .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='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
attribution='© <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" />
|
||||||
|
{editing ? (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select label="Ausbaustandard"
|
||||||
|
size="small" fullWidth
|
||||||
|
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||||
|
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ value: '', label: 'Keine Angabe' },
|
||||||
|
{ value: 'SHELL', label: 'Rohbau' },
|
||||||
|
{ value: 'BASIC', label: 'Basisausbau' },
|
||||||
|
{ value: 'FULL', label: 'Vollausbau' },
|
||||||
|
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||||
|
].map(o => <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>
|
<FieldGrid>
|
||||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
<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="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||||
</FieldGrid>
|
</FieldGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
{p.location.coordinates && (
|
{p.location.coordinates && (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} m²`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
criterion: 'fitOut',
|
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
|
||||||
weight: 0.04,
|
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
|
||||||
score,
|
}
|
||||||
contribution: score * 0.04,
|
}
|
||||||
explanation: propLevel >= reqLevel
|
// Check if combined budget (tenant + MAB) covers the upgrade gap
|
||||||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
|
||||||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
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 {
|
||||||
|
criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04,
|
||||||
|
explanation: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich${budgetNote}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. 700–900m², Budget max CHF 42/m², Bezug Herbst 2025.',
|
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700–900m², 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, 500–800m², max. CHF 38/m², Bezug Q4 2025.',
|
extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500–800m², max. CHF 38/m², Bezug Q4 2025.',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
|||||||
+35
-13
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -177,6 +208,7 @@ export default function Results() {
|
|||||||
Suche ändern
|
Suche ändern
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>>
|
||||||
|
|||||||
@@ -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 (min–max 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))
|
||||||
|
},
|
||||||
|
}
|
||||||
+25
-22
@@ -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 (provider === 'openrouter') {
|
||||||
if (!apiKey) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
'[aiService] OpenRouter selected but VITE_OPENROUTER_API_KEY is missing — falling back to MockAIService.',
|
'[aiService] VITE_AI_PROVIDER=openrouter is no longer supported. ' +
|
||||||
'Set VITE_AI_PROVIDER=mock to suppress this warning.',
|
'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.',
|
||||||
)
|
)
|
||||||
return MockAIService
|
|
||||||
}
|
|
||||||
return OpenRouterAIService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return MockAIService
|
// 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 }
|
||||||
|
|||||||
@@ -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 200–400/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 () => ({
|
||||||
|
|||||||
@@ -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 (min–max 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} m²`)
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user