Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 362514681a | |||
| 07cea21143 | |||
| be5523bcee | |||
| a8b54af0b8 | |||
| 6206447dae | |||
| 8cb6f21581 | |||
| 29be4f6e54 | |||
| 09095a1a32 | |||
| fb029cf0bc | |||
| 3e1e945661 |
Generated
+48
@@ -41,6 +41,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.61.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
@@ -4330,6 +4331,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.61.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
|
import type { InquiryFilters } from '../../provider/IInquiryProvider'
|
||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { InquiryList } from './InquiryList'
|
import { InquiryList } from './InquiryList'
|
||||||
import { InquiryCardGrid } from './InquiryCardGrid'
|
import { InquiryCardGrid } from './InquiryCardGrid'
|
||||||
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
|
|||||||
return stored === 'grid' ? 'grid' : 'list'
|
return stored === 'grid' ? 'grid' : 'list'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveInquiriesTab() {
|
interface Props {
|
||||||
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
filters?: InquiryFilters
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
|
||||||
|
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<ViewMode>(loadViewMode())
|
const [view, setView] = useState<ViewMode>(loadViewMode())
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Inbox size={40} />}
|
icon={<Inbox size={40} />}
|
||||||
title="Keine aktiven Anfragen"
|
title={emptyTitle ?? 'Keine aktiven Anfragen'}
|
||||||
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
|
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
|
||||||
|
import { Building2, MapPin } from 'lucide-react'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { assetTypeLabel } from './latentNeedUtils'
|
||||||
|
|
||||||
|
interface EmbeddedPropertyCardProps {
|
||||||
|
property: Property
|
||||||
|
matchScore: number
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
reason: string
|
||||||
|
onQuickOffer: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbeddedPropertyCard({
|
||||||
|
property,
|
||||||
|
matchScore,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
reason,
|
||||||
|
onQuickOffer,
|
||||||
|
disabled,
|
||||||
|
}: EmbeddedPropertyCardProps) {
|
||||||
|
const tier = getScoreTier(matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||||
|
bgcolor: selected ? '#f8fafc' : 'white',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Info row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!image && <Building2 size={24} color="#94a3b8" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: theme.gradient,
|
||||||
|
color: theme.text,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.375,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
|
||||||
|
{reason}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #f1f5f9',
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={!disabled ? onToggle : undefined}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
|
||||||
|
Für Angebot auswählen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onQuickOffer}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.775rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderColor: '#152642',
|
||||||
|
color: '#152642',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1.5,
|
||||||
|
minWidth: 0,
|
||||||
|
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Angebot →
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
|
|||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { PublicNeedList } from './PublicNeedList'
|
import { PublicNeedList } from './PublicNeedList'
|
||||||
import { PublicNeedDetail } from './PublicNeedDetail'
|
import { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
|
||||||
|
|
||||||
type MobileView = 'list' | 'detail' | 'properties'
|
type MobileView = 'list' | 'detail' | 'properties'
|
||||||
|
|
||||||
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
|
|||||||
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { useSendOffer } from '../../hooks/useOffers'
|
import { useSendOffer } from '../../hooks/useOffers'
|
||||||
|
import { useCreateOffer } from '../../hooks/useInquiries'
|
||||||
|
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
|
|||||||
const reset = useOfferWizardStore(s => s.reset)
|
const reset = useOfferWizardStore(s => s.reset)
|
||||||
|
|
||||||
const sendOffer = useSendOffer()
|
const sendOffer = useSendOffer()
|
||||||
|
const createOffer = useCreateOffer()
|
||||||
|
const { data: latentNeed } = useLatentNeedById(needId)
|
||||||
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
const propertyTitles = selectedPropertyIds.map(id => {
|
const propertyTitles = selectedPropertyIds.map(id => {
|
||||||
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
|
|||||||
showToast(`Fehler: ${res.error}`, 'error')
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Angebot als Konversation im Nachfrager-Postfach materialisieren
|
||||||
|
await createOffer.mutateAsync({
|
||||||
|
organizationId: currentUser?.organizationId ?? 'org-wincasa',
|
||||||
|
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
|
||||||
|
needId: needId ?? '',
|
||||||
|
propertyId: selectedPropertyIds[0] ?? '',
|
||||||
|
offeredPropertyIds: selectedPropertyIds,
|
||||||
|
subject,
|
||||||
|
message: body,
|
||||||
|
senderName: currentUser?.organizationName ?? 'Wincasa AG',
|
||||||
|
recipientName: latentNeed?.tenantCompany,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
showToast('Angebot erfolgreich gesendet', 'success')
|
showToast('Angebot erfolgreich gesendet', 'success')
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { MapPin, Ruler } from 'lucide-react'
|
import { MapPin } from 'lucide-react'
|
||||||
import type { LatentNeed } from '../../domain/latentNeed'
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
|
||||||
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
|
|||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
|
||||||
|
if (status === 'public') return '#16a34a'
|
||||||
|
if (status === 'paused') return '#d97706'
|
||||||
|
return '#94a3b8'
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
|
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
|
||||||
const statusCfg = latentStatusBadge(need.status)
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
return (
|
return (
|
||||||
@@ -36,17 +42,14 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
|
||||||
{need.title}
|
{need.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
label={statusCfg.label}
|
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
|
||||||
size="small"
|
<Chip
|
||||||
sx={{
|
label={statusCfg.label}
|
||||||
bgcolor: statusCfg.bg,
|
size="small"
|
||||||
color: statusCfg.fg,
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||||
fontWeight: 600,
|
/>
|
||||||
fontSize: '0.65rem',
|
</Box>
|
||||||
height: 20,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{need.tenantCompany && (
|
{need.tenantCompany && (
|
||||||
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
|||||||
{need.desiredLocation}
|
{need.desiredLocation}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
|
|
||||||
<Ruler size={12} />
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
|
||||||
{need.sizeRange.min}–{need.sizeRange.max} m²
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,242 +1,248 @@
|
|||||||
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
import { useMemo, useState } from 'react'
|
||||||
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
|
import {
|
||||||
|
Accordion, AccordionDetails, AccordionSummary,
|
||||||
|
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
|
||||||
import type { LatentNeed } from '../../domain/latentNeed'
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
|
import { ResultType, UserRole } from '../../domain/enums'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { UserRole } from '../../domain/enums'
|
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
|
|
||||||
interface PublicNeedDetailProps {
|
interface PublicNeedDetailProps {
|
||||||
need: LatentNeed
|
need: LatentNeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReason(propType: string, needType: string, city: string, location: string): string {
|
||||||
|
if (propType === needType) {
|
||||||
|
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
|
||||||
|
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
|
||||||
|
}
|
||||||
|
return 'Alternatives Profil — Detailprüfung empfohlen'
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
||||||
const openWizard = useOfferWizardStore(s => s.open)
|
const openWizard = useOfferWizardStore(s => s.open)
|
||||||
|
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
|
||||||
|
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
|
||||||
const currentUser = useSessionStore(s => s.currentUser)
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const statusCfg = latentStatusBadge(need.status)
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
|
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
|
||||||
|
|
||||||
|
const scored = useMemo(
|
||||||
|
() =>
|
||||||
|
properties
|
||||||
|
.map(p => ({
|
||||||
|
property: p,
|
||||||
|
score: deterministicMatchScore(p.id, need.id),
|
||||||
|
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score),
|
||||||
|
[properties, need],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = showAll ? scored : scored.slice(0, 3)
|
||||||
|
const remaining = scored.length - 3
|
||||||
|
|
||||||
|
function handleQuickOffer(propertyId: string) {
|
||||||
|
setSelectedProperties([propertyId])
|
||||||
|
openWizard(need.id, need.title)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
sx={{
|
{/* Scrollable content */}
|
||||||
flex: 1,
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
overflowY: 'auto',
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Chip
|
<Chip label={assetTypeLabel(need.assetType)} size="small"
|
||||||
label={assetTypeLabel(need.assetType)}
|
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
size="small"
|
<Chip label={statusCfg.label} size="small"
|
||||||
sx={{
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
color: '#3730a3',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: 22,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
label={statusCfg.label}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: statusCfg.bg,
|
|
||||||
color: statusCfg.fg,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: 22,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
||||||
{need.title}
|
{need.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{need.tenantCompany && (
|
{need.tenantCompany && (
|
||||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
|
||||||
{need.tenantCompany}
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* AI Summary */}
|
{/* KI-Entscheidungsbrief */}
|
||||||
{need.aiSummary && (
|
{need.aiSummary && (
|
||||||
<Alert
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
|
||||||
icon={<Sparkles size={16} />}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||||
severity="info"
|
<Sparkles size={16} color="#1d4ed8" />
|
||||||
sx={{
|
<Typography variant="caption"
|
||||||
bgcolor: '#eff6ff',
|
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
|
||||||
borderRadius: 1.5,
|
KI-Entscheidungsbrief
|
||||||
border: '1px solid #bfdbfe',
|
</Typography>
|
||||||
'& .MuiAlert-icon': { color: '#1d4ed8' },
|
</Box>
|
||||||
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
|
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
|
||||||
}}
|
{need.aiSummary}
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
|
|
||||||
KI-Analyse
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{need.aiSummary}
|
</Paper>
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suchprofil grid */}
|
{/* Suchprofil */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
variant="caption"
|
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
Suchprofil
|
Suchprofil
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
|
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
|
||||||
<ProfileBox
|
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}–${need.sizeRange.max} m²`} />
|
||||||
icon={<Ruler size={14} />}
|
|
||||||
label="Fläche"
|
|
||||||
value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
|
|
||||||
/>
|
|
||||||
<ProfileBox
|
<ProfileBox
|
||||||
icon={<Wallet size={14} />}
|
icon={<Wallet size={14} />}
|
||||||
label="Budget"
|
label="Budget"
|
||||||
value={
|
value={need.budgetRange
|
||||||
need.budgetRange
|
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
|
||||||
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${
|
: 'Flexibel'}
|
||||||
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
|
|
||||||
} /m²`
|
|
||||||
: 'Flexibel'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Must-have Kriterien */}
|
{/* Must-haves */}
|
||||||
{need.mustHaveCriteria.length > 0 && (
|
{need.mustHaveCriteria.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
variant="caption"
|
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
Must-have Kriterien
|
Must-have Kriterien
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{need.mustHaveCriteria.map((c, idx) => (
|
{need.mustHaveCriteria.map((c, idx) => (
|
||||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CheckCircle2 size={14} color="#16a34a" />
|
<CheckCircle2 size={14} color="#16a34a" />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||||
{c}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gewichtete Präferenzen */}
|
{/* Gewichtete Präferenzen — Accordion */}
|
||||||
{need.weightedPreferences.length > 0 && (
|
{need.weightedPreferences.length > 0 && (
|
||||||
<Box>
|
<Accordion elevation={0} disableGutters
|
||||||
<Typography
|
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
|
||||||
variant="caption"
|
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||||
>
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
Gewichtete Präferenzen
|
Gewichtete Präferenzen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
</AccordionSummary>
|
||||||
{need.weightedPreferences.map((p, idx) => {
|
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||||
const pct = Math.round(p.weight * 100)
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
return (
|
{need.weightedPreferences.map((p, idx) => {
|
||||||
<Box
|
const pct = Math.round(p.weight * 100)
|
||||||
key={idx}
|
return (
|
||||||
sx={{
|
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
bgcolor: 'white',
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
border: '1px solid #e2e8f0',
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
|
||||||
borderRadius: 1.5,
|
<Typography variant="caption"
|
||||||
px: 1.5,
|
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
|
||||||
py: 1,
|
{pct}%
|
||||||
}}
|
</Typography>
|
||||||
>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||||
{p.criterion}
|
</Box>
|
||||||
</Typography>
|
{p.description && (
|
||||||
<Typography
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
||||||
variant="caption"
|
{p.description}
|
||||||
sx={{
|
</Typography>
|
||||||
fontWeight: 700,
|
)}
|
||||||
color: '#152642',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
px: 1,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pct}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
)
|
||||||
sx={{
|
})}
|
||||||
height: 6,
|
</Box>
|
||||||
borderRadius: 3,
|
</AccordionDetails>
|
||||||
bgcolor: '#e8e7e4',
|
</Accordion>
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${pct}%`,
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: '#152642',
|
|
||||||
transition: 'width 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{p.description && (
|
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
|
||||||
{p.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Top Empfehlungen */}
|
||||||
|
<Box>
|
||||||
|
<Divider sx={{ mb: 2.5 }} />
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Target size={14} color="#152642" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Ihre Top Empfehlungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{propertiesLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
|
||||||
|
) : scored.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
|
||||||
|
Keine Portfolio-Objekte vorhanden
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||||
|
{visible.map(({ property, score, reason }) => (
|
||||||
|
<EmbeddedPropertyCard
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
matchScore={score}
|
||||||
|
selected={selectedIds.includes(property.id)}
|
||||||
|
onToggle={() => toggleProperty(property.id)}
|
||||||
|
reason={reason}
|
||||||
|
onQuickOffer={() => handleQuickOffer(property.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<Box
|
||||||
|
onClick={() => setShowAll(s => !s)}
|
||||||
|
sx={{
|
||||||
|
py: 1.25,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px dashed #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sticky footer CTA */}
|
||||||
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
|
||||||
{disabled && need.status !== 'public' && (
|
{disabled && need.status !== 'public' && (
|
||||||
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
|
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
|
||||||
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
|
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{selectedIds.length > 0 && !disabled && (
|
||||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
|
||||||
<Button
|
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
|
||||||
variant="contained"
|
</Typography>
|
||||||
size="large"
|
)}
|
||||||
fullWidth
|
<Button
|
||||||
onClick={() => openWizard(need.id, need.title)}
|
variant="contained"
|
||||||
disabled={disabled}
|
size="large"
|
||||||
sx={{
|
fullWidth
|
||||||
textTransform: 'none',
|
onClick={() => openWizard(need.id, need.title)}
|
||||||
bgcolor: '#152642',
|
disabled={disabled}
|
||||||
fontWeight: 600,
|
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
|
||||||
'&:hover': { bgcolor: '#16304d' },
|
>
|
||||||
}}
|
Angebot erstellen
|
||||||
>
|
</Button>
|
||||||
Angebot erstellen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -244,24 +250,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
|||||||
|
|
||||||
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
sx={{
|
|
||||||
bgcolor: 'white',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
borderRadius: 1.5,
|
|
||||||
px: 1.5,
|
|
||||||
py: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||||
{icon}
|
{icon}
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: fullWidth ? '100%' : 280,
|
width: fullWidth ? '100%' : 260,
|
||||||
minWidth: fullWidth ? 'unset' : 280,
|
minWidth: fullWidth ? 'unset' : 260,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
|
|||||||
export { PublicNeedDetail } from './PublicNeedDetail'
|
export { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||||
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||||
|
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
export { OfferCreationPanel } from './OfferCreationPanel'
|
export { OfferCreationPanel } from './OfferCreationPanel'
|
||||||
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ export function AppShell() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||||
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
|
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
// Auto-collapse sidebar on compact screens (laptops < 1536px)
|
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
|
||||||
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
||||||
|
|
||||||
// Sync active workspace with URL
|
// Sync active workspace with URL
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
overlayTypeLabel?: string
|
|
||||||
overlayLocationLabel?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
@@ -59,7 +57,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} aspectRatio="15/8" />
|
||||||
|
|
||||||
{/* Score badge — bottom-left editorial */}
|
{/* Score badge — bottom-left editorial */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
import { Send } from 'lucide-react'
|
import { Send } from 'lucide-react'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useMoveStage } from '../../hooks/usePipeline'
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { useCreateInquiry } from '../../hooks/useInquiries'
|
||||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
|
|||||||
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||||
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
const addInquiry = useInquiryStore(s => s.addInquiry)
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
|
const createInquiry = useCreateInquiry()
|
||||||
const { mutate: moveStage } = useMoveStage()
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
|
|||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
if (!pendingInquiry) return
|
if (!pendingInquiry) return
|
||||||
addInquiry(pendingInquiry, message)
|
createInquiry.mutate({
|
||||||
|
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
|
||||||
|
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
|
||||||
|
propertyId: pendingInquiry.propertyId ?? 'unknown',
|
||||||
|
tenantName: currentUser?.name ?? 'Demand User',
|
||||||
|
tenantCompany: currentUser?.organizationName,
|
||||||
|
tenantEmail: currentUser?.email,
|
||||||
|
propertyAddress: pendingInquiry.propertyTitle,
|
||||||
|
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
|
||||||
|
message,
|
||||||
|
matchScore: pendingInquiry.matchScore,
|
||||||
|
})
|
||||||
if (pendingInquiry.pipelineItemId) {
|
if (pendingInquiry.pipelineItemId) {
|
||||||
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface Props {
|
|||||||
futureCount: number
|
futureCount: number
|
||||||
view?: 'list' | 'grid'
|
view?: 'list' | 'grid'
|
||||||
onViewChange?: (v: 'list' | 'grid') => void
|
onViewChange?: (v: 'list' | 'grid') => void
|
||||||
|
columns?: 3 | 5 | 10
|
||||||
|
onColumnsChange?: (n: 3 | 5 | 10) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange }: Props) {
|
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange, columns = 3, onColumnsChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -21,9 +23,30 @@ export function ResultFeedHeader({ total, platformCount, maisonWorkCount, future
|
|||||||
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
|
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{onViewChange && (
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<ViewToggle view={view} onChange={onViewChange} />
|
{view === 'grid' && onColumnsChange && (
|
||||||
)}
|
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||||
|
{([3, 5, 10] as const).map(n => (
|
||||||
|
<Box
|
||||||
|
key={n}
|
||||||
|
onClick={() => onColumnsChange(n)}
|
||||||
|
sx={{
|
||||||
|
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
|
||||||
|
bgcolor: columns === n ? '#152642' : 'transparent',
|
||||||
|
color: columns === n ? 'white' : '#64748b',
|
||||||
|
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{onViewChange && (
|
||||||
|
<ViewToggle view={view} onChange={onViewChange} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -98,17 +97,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
? result.property.location.city
|
? result.property.location.city
|
||||||
: result.signal.locationHint ?? undefined
|
: result.signal.locationHint ?? undefined
|
||||||
|
|
||||||
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
|
||||||
? resolveImageLabel({
|
|
||||||
assetType: result.property.assetType,
|
|
||||||
city: result.property.location.city,
|
|
||||||
district: result.property.location.district,
|
|
||||||
prestige: result.property.softFactors?.prestige,
|
|
||||||
floorLevel: result.property.floorLevel,
|
|
||||||
propertyId: result.property.id,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntelligenceMatchCard
|
<IntelligenceMatchCard
|
||||||
vm={vm}
|
vm={vm}
|
||||||
@@ -116,8 +104,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
lat={lat}
|
lat={lat}
|
||||||
lng={lng}
|
lng={lng}
|
||||||
cityLabel={cityLabel}
|
cityLabel={cityLabel}
|
||||||
overlayTypeLabel={overlayLabels?.type}
|
|
||||||
overlayLocationLabel={overlayLabels?.location}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SCORE_THEME } from '../shared/scoreTheme'
|
|||||||
interface Props {
|
interface Props {
|
||||||
results: UnifiedMatchResult[]
|
results: UnifiedMatchResult[]
|
||||||
view?: 'list' | 'grid'
|
view?: 'list' | 'grid'
|
||||||
|
columns?: 3 | 5 | 10
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_CONFIG = [
|
const TIER_CONFIG = [
|
||||||
@@ -40,7 +41,7 @@ interface TierHeaderProps {
|
|||||||
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
|
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
|
||||||
const theme = SCORE_THEME[tierKey]
|
const theme = SCORE_THEME[tierKey]
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 4.5, mb: 2.5 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 4,
|
width: 4,
|
||||||
@@ -72,7 +73,7 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
export function UnifiedResultFeed({ results, view = 'list', columns = 3 }: Props) {
|
||||||
// All results — real properties and Schattenmarkt signals — are sorted into
|
// All results — real properties and Schattenmarkt signals — are sorted into
|
||||||
// the same score tiers so they appear in a single unified ranked list.
|
// the same score tiers so they appear in a single unified ranked list.
|
||||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
||||||
@@ -80,7 +81,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
|||||||
|
|
||||||
function renderCards(items: UnifiedMatchResult[]) {
|
function renderCards(items: UnifiedMatchResult[]) {
|
||||||
return view === 'grid' ? (
|
return view === 'grid' ? (
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3 }}>
|
||||||
{items.map(result => (
|
{items.map(result => (
|
||||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
/** When set, the hero scales proportionally with width (e.g. '15/8') instead of using a fixed pixel height. */
|
||||||
|
aspectRatio?: string
|
||||||
overlayTypeLabel?: string
|
overlayTypeLabel?: string
|
||||||
overlayLocationLabel?: string
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
@@ -23,13 +25,13 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
|||||||
return 'https://www.google.com/maps'
|
return 'https://www.google.com/maps'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, aspectRatio, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||||
const showImage = !!imageUrl && !imgError
|
const showImage = !!imageUrl && !imgError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'relative', width: '100%', height, overflow: 'hidden', flexShrink: 0 }}>
|
<Box sx={{ position: 'relative', width: '100%', ...(aspectRatio ? { aspectRatio } : { height }), overflow: 'hidden', flexShrink: 0 }}>
|
||||||
{showImage ? (
|
{showImage ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import type { Property, PropertyUnit } from '../../domain/property'
|
||||||
|
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
|
||||||
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
|
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
|
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||||
|
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
|
||||||
|
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
|
||||||
|
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
|
||||||
|
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
fitOut?: string
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
mieterausbaubeitragPerSqm?: number
|
||||||
|
parkingSpots?: number
|
||||||
|
expectedRentPerSqm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
property: Property
|
||||||
|
unit: PropertyUnit
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
|
||||||
|
export function PreMarketPriceAdvisor({ property, unit }: Props) {
|
||||||
|
const updateUnit = useUpdateUnit(property.id)
|
||||||
|
const [d, setD] = useState<Draft>(() => ({
|
||||||
|
fitOut: unit.fitOut ?? '',
|
||||||
|
fitOutByLandlord: unit.fitOutByLandlord,
|
||||||
|
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||||
|
parkingSpots: unit.parkingSpots,
|
||||||
|
expectedRentPerSqm: unit.expectedRentPerSqm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const facts = resolveUnitFacts(property, unit)
|
||||||
|
const current = facts.rentPricePerSqm
|
||||||
|
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||||
|
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = usePreMarketRentRecommendation({
|
||||||
|
city: property.location?.city ?? '',
|
||||||
|
assetType: property.assetType,
|
||||||
|
areaSqm: unit.areaSqm,
|
||||||
|
currentRentPerSqm: current,
|
||||||
|
availableFrom: unit.schattenmarktRelease?.availableFrom,
|
||||||
|
})
|
||||||
|
const rec = data?.data
|
||||||
|
const verdict = rec ? VERDICT_META[rec.verdict] : null
|
||||||
|
|
||||||
|
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 0.75, p: 1.25, borderRadius: 1, bgcolor: 'white', border: `1px solid ${DS_SURFACE.purple.border}` }}>
|
||||||
|
{/* 1) Ausbau & Ausstattung */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
select size="small" label="Ausbaustandard"
|
||||||
|
value={d.fitOut ?? ''}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value
|
||||||
|
const nb = v === 'SHELL' || v === 'BASIC'
|
||||||
|
setD(p => ({ ...p, fitOut: v }))
|
||||||
|
save({ fitOut: (v || undefined) as PropertyUnit['fitOut'], ...(nb ? {} : { fitOutByLandlord: undefined, mieterausbaubeitragPerSqm: undefined }) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
|
||||||
|
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||||
|
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||||
|
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||||
|
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Parkplätze" type="number" size="small"
|
||||||
|
value={d.parkingSpots ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ parkingSpots: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
helperText={property.hardFacts?.parking != null ? `Pool: ${property.hardFacts.parking}` : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{needsBuild && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive size="small"
|
||||||
|
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
if (!v) return
|
||||||
|
const landlord = v === 'landlord'
|
||||||
|
setD(p => ({ ...p, fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) }))
|
||||||
|
save({ fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||||
|
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
{d.fitOutByLandlord === false && (
|
||||||
|
<TextField
|
||||||
|
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 140 }}
|
||||||
|
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.25 }} />
|
||||||
|
|
||||||
|
{/* 2) KI-Preisempfehlung */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
|
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
|
||||||
|
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<CircularProgress size={12} sx={{ color: DS_PRE_MARKET.accent }} />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9' }}>Marktanalyse läuft…</Typography>
|
||||||
|
</Box>
|
||||||
|
) : isError || !rec ? (
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
|
||||||
|
Empfehlung: CHF {rec.recommendedPerSqm}/m²
|
||||||
|
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm})</Box>
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.25 }}>
|
||||||
|
Heute CHF {current}/m²
|
||||||
|
<Box component="span" sx={{ fontWeight: 700, ml: 0.5 }}>
|
||||||
|
{rec.deltaVsCurrentPct > 0 ? `+${rec.deltaVsCurrentPct}%` : `${rec.deltaVsCurrentPct}%`} vs. heute
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', color: '#7c3aed', mb: 0.625 }}>
|
||||||
|
{rec.drivers.join(' · ')}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<TextField
|
||||||
|
type="number" size="small" label="Pre-Market-Preis"
|
||||||
|
placeholder={`z.B. ${rec.recommendedPerSqm}`}
|
||||||
|
value={d.expectedRentPerSqm ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
|
||||||
|
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
|
||||||
|
>
|
||||||
|
Empfehlung übernehmen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { FIT_OUT_LABELS } from '../../lib/constants'
|
|||||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
import { floorLabel } from './PropertyDetailHelpers'
|
import { floorLabel } from './PropertyDetailHelpers'
|
||||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||||
|
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
|
||||||
|
|
||||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||||
|
|
||||||
@@ -114,14 +115,16 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit + enabled toggle */}
|
{/* Edit (nur vor Freigabe — danach inline) + enabled toggle */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
||||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||||
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
{!us.enabled && (
|
||||||
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
|
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
||||||
<Pencil size={12} />
|
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
|
||||||
</IconButton>
|
<Pencil size={12} />
|
||||||
</Tooltip>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={us.enabled}
|
checked={us.enabled}
|
||||||
@@ -138,17 +141,21 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Always-visible per-unit details */}
|
{/* Vor Freigabe: Lese-Details + Stift-Editor. Nach Freigabe: volle Inline-Konfiguration. */}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
{us.enabled ? (
|
||||||
{detailParts.join(' · ')}
|
<PreMarketPriceAdvisor property={property} unit={u} />
|
||||||
</Typography>
|
) : (
|
||||||
|
<>
|
||||||
{/* Inline editor (shared) */}
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
||||||
<Collapse in={editing === u.id}>
|
{detailParts.join(' · ')}
|
||||||
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
</Typography>
|
||||||
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
<Collapse in={editing === u.id}>
|
||||||
</Box>
|
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
||||||
</Collapse>
|
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typo
|
|||||||
import type { Property, PropertyUnit } from '../../domain/property'
|
import type { Property, PropertyUnit } from '../../domain/property'
|
||||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
import { suggestFutureRent } from '../../lib/rentEstimate'
|
|
||||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||||
@@ -15,7 +14,6 @@ interface UnitDraft {
|
|||||||
fitOutByLandlord?: boolean
|
fitOutByLandlord?: boolean
|
||||||
mieterausbaubeitragPerSqm?: number
|
mieterausbaubeitragPerSqm?: number
|
||||||
parkingSpots?: number
|
parkingSpots?: number
|
||||||
expectedRentPerSqm?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,7 +22,7 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared per-unit editor: price, availability, fit-out, cost-bearer, MAB, parking allocation, expected price. */
|
/** Shared per-unit editor: conditions (price, availability), fit-out group, parking allocation. */
|
||||||
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||||
const updateUnit = useUpdateUnit(property.id)
|
const updateUnit = useUpdateUnit(property.id)
|
||||||
const [d, setD] = useState<UnitDraft>(() => ({
|
const [d, setD] = useState<UnitDraft>(() => ({
|
||||||
@@ -34,12 +32,10 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
fitOutByLandlord: unit.fitOutByLandlord,
|
fitOutByLandlord: unit.fitOutByLandlord,
|
||||||
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||||
parkingSpots: unit.parkingSpots,
|
parkingSpots: unit.parkingSpots,
|
||||||
expectedRentPerSqm: unit.expectedRentPerSqm,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const effFit = d.fitOut || property.hardFacts?.fitOut
|
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||||
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||||
const suggestion = suggestFutureRent(property.location?.city ?? '', d.rentPricePerSqm ?? unit.rentPricePerSqm ?? property.rentPricePerSqm)
|
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||||
@@ -53,15 +49,15 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
|
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
|
||||||
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
||||||
parkingSpots: d.parkingSpots,
|
parkingSpots: d.parkingSpots,
|
||||||
expectedRentPerSqm: d.expectedRentPerSqm,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25 }}>
|
{/* Konditionen */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||||
value={d.rentPricePerSqm ?? ''}
|
value={d.rentPricePerSqm ?? ''}
|
||||||
@@ -79,7 +75,21 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
select size="small" label="Ausbaustandard"
|
label="Parkplätze (Zuteilung)" type="number" size="small"
|
||||||
|
value={d.parkingSpots ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
helperText={property.hardFacts?.parking != null ? `Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Ausbau — Standard + Träger + MAB als Gruppe */}
|
||||||
|
<Box sx={{ mt: 1.5, p: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, bgcolor: 'white' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 1 }}>
|
||||||
|
Ausbau
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
select size="small" label="Ausbaustandard" fullWidth
|
||||||
value={d.fitOut ?? ''}
|
value={d.fitOut ?? ''}
|
||||||
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
||||||
>
|
>
|
||||||
@@ -89,49 +99,32 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
|||||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
|
||||||
label="Parkplätze (Zuteilung)" type="number" size="small"
|
{needsBuild && (
|
||||||
value={d.parkingSpots ?? ''}
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||||
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
<ToggleButtonGroup
|
||||||
helperText={property.hardFacts?.parking != null ? `aus Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
exclusive size="small"
|
||||||
/>
|
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||||
|
onChange={(_, v) => { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||||
|
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
{d.fitOutByLandlord === false && (
|
||||||
|
<TextField
|
||||||
|
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 150 }}
|
||||||
|
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||||
|
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, justifyContent: 'flex-end' }}>
|
||||||
<TextField
|
|
||||||
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
|
|
||||||
value={d.expectedRentPerSqm ?? ''}
|
|
||||||
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
|
||||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
|
||||||
helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{needsBuild && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
exclusive size="small"
|
|
||||||
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
|
||||||
onChange={(_, v) => { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
|
||||||
>
|
|
||||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
|
||||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
{d.fitOutByLandlord === false && (
|
|
||||||
<TextField
|
|
||||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
|
|
||||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
|
||||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
|
||||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.25, justifyContent: 'flex-end' }}>
|
|
||||||
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
|
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -20,10 +20,20 @@ export interface InquiryMessage {
|
|||||||
|
|
||||||
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
|
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
|
||||||
|
|
||||||
|
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
|
||||||
|
export type InquiryKind = 'INQUIRY' | 'OFFER'
|
||||||
|
|
||||||
export interface Inquiry {
|
export interface Inquiry {
|
||||||
id: string
|
id: string
|
||||||
|
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
/** Nachfrager-Organisation — für das Demand-Postfach. */
|
||||||
|
tenantOrgId?: string
|
||||||
|
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
|
||||||
|
kind?: InquiryKind
|
||||||
propertyId: string
|
propertyId: string
|
||||||
|
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
|
||||||
|
offeredPropertyIds?: string[]
|
||||||
needId?: string
|
needId?: string
|
||||||
tenantName: string
|
tenantName: string
|
||||||
tenantCompany?: string
|
tenantCompany?: string
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface LatentNeed {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
tenantCompany?: string
|
tenantCompany?: string
|
||||||
|
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
|
||||||
|
tenantOrgId?: string
|
||||||
assetType: AssetType
|
assetType: AssetType
|
||||||
desiredLocation: string
|
desiredLocation: string
|
||||||
sizeRange: { min: number; max: number }
|
sizeRange: { min: number; max: number }
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import { aiService, parseListingText } from '../services/aiService'
|
import { aiService, parseListingText } from '../services/aiService'
|
||||||
import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
|
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
|
||||||
|
|
||||||
export function useParseNeed() {
|
export function useParseNeed() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -34,3 +34,12 @@ export function useFitOutAdvice(input: FitOutAdviceInput | null) {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['preMarketRent', input],
|
||||||
|
queryFn: () => aiService.recommendPreMarketRent(input!),
|
||||||
|
enabled: !!input && !!input.city,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { inquiryService } from '../services/inquiryService'
|
import { inquiryService } from '../services/inquiryService'
|
||||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||||
import type { Attachment } from '../domain/inquiry'
|
import type { Attachment, InquiryMessage } from '../domain/inquiry'
|
||||||
import { useToastStore } from '../stores/toastStore'
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
|
||||||
export function useActiveInquiries(filters?: InquiryFilters) {
|
export function useActiveInquiries(filters?: InquiryFilters) {
|
||||||
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
|
|||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
inquiryId: string
|
inquiryId: string
|
||||||
payload: { subject?: string; body: string; attachments?: Attachment[] }
|
payload: { subject?: string; body: string; attachments?: Attachment[]; senderType?: InquiryMessage['senderType']; senderName?: string }
|
||||||
}) => inquiryService.sendInquiryReply(inquiryId, payload),
|
}) => inquiryService.sendInquiryReply(inquiryId, payload),
|
||||||
onSuccess: (_data, { inquiryId }) => {
|
onSuccess: (_data, { inquiryId }) => {
|
||||||
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
|
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
|
||||||
@@ -41,6 +41,31 @@ export function useSendInquiryReply() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Demand→Supply: neue Anfrage erstellen (ersetzt die alte Zustand-Insel). */
|
||||||
|
export function useCreateInquiry() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateInquiryInput) => inquiryService.createInquiry(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Anfrage konnte nicht gesendet werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supply→Demand: Angebot als Konversation im Nachfrager-Postfach materialisieren. */
|
||||||
|
export function useCreateOffer() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateOfferInput) => inquiryService.createOffer(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useMarkThreadAsRead() {
|
export function useMarkThreadAsRead() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
||||||
|
|
||||||
describe('estimateMarketRent', () => {
|
describe('estimateMarketRent', () => {
|
||||||
|
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
|
||||||
it('flags asking rent clearly above the city median as ABOVE', () => {
|
it('flags asking rent clearly above the city median as ABOVE', () => {
|
||||||
// Zürich OFFICE median = 42
|
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 60)
|
|
||||||
expect(est).not.toBeNull()
|
expect(est).not.toBeNull()
|
||||||
expect(est!.verdict).toBe('ABOVE')
|
expect(est!.verdict).toBe('ABOVE')
|
||||||
expect(est!.deltaPct).toBeGreaterThan(5)
|
expect(est!.deltaPct).toBeGreaterThan(5)
|
||||||
expect(est!.fairRentPerSqm).toBe(42)
|
expect(est!.fairRentPerSqm).toBe(504)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flags asking rent clearly below median as BELOW', () => {
|
it('flags asking rent clearly below median as BELOW', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 30)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
|
||||||
expect(est!.verdict).toBe('BELOW')
|
expect(est!.verdict).toBe('BELOW')
|
||||||
expect(est!.deltaPct).toBeLessThan(-5)
|
expect(est!.deltaPct).toBeLessThan(-5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('treats near-median asking rent as AT (within ±5%)', () => {
|
it('treats near-median asking rent as AT (within ±5%)', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 43)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
|
||||||
expect(est!.verdict).toBe('AT')
|
expect(est!.verdict).toBe('AT')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null for unknown city', () => {
|
it('returns null for unknown city', () => {
|
||||||
expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull()
|
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suggestFutureRent indexes by the city rent trend', () => {
|
it('suggestFutureRent indexes by the city rent trend', () => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export interface CityIntelligence {
|
|||||||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||||||
dominantIndustryClusters: string[]
|
dominantIndustryClusters: string[]
|
||||||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||||||
medianRentOffice: number // CHF/m² für Bürofläche
|
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
|
||||||
medianRentLogistics: number
|
medianRentLogistics: number // CHF/m²/Monat
|
||||||
medianRentRetail: number
|
medianRentRetail: number // CHF/m²/Monat
|
||||||
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
||||||
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||||
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
||||||
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
|
|||||||
return key ? CITY_INTELLIGENCE[key] : null
|
return key ? CITY_INTELLIGENCE[key] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
|
||||||
export function getMarketRent(city: string, assetType: string): number | null {
|
export function getMarketRent(city: string, assetType: string): number | null {
|
||||||
const intel = getCityIntelligence(city)
|
const intel = getCityIntelligence(city)
|
||||||
if (!intel) return null
|
if (!intel) return null
|
||||||
if (assetType === 'OFFICE') return intel.medianRentOffice
|
const monthly =
|
||||||
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
|
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
|
||||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
: assetType === 'RETAIL' ? intel.medianRentRetail
|
||||||
return intel.medianRentOffice
|
: intel.medianRentOffice
|
||||||
|
return monthly * 12
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
{
|
{
|
||||||
id: 'dinq-001',
|
id: 'dinq-001',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'INQUIRY',
|
||||||
propertyId: 'prop-technopark',
|
propertyId: 'prop-technopark',
|
||||||
tenantName: 'Admin User',
|
tenantName: 'Admin User',
|
||||||
tenantCompany: 'Mobimo Management AG',
|
tenantCompany: 'Mobimo Management AG',
|
||||||
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
{
|
{
|
||||||
id: 'dinq-002',
|
id: 'dinq-002',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'INQUIRY',
|
||||||
propertyId: 'prop-hardturmpark',
|
propertyId: 'prop-hardturmpark',
|
||||||
tenantName: 'Admin User',
|
tenantName: 'Admin User',
|
||||||
tenantCompany: 'Mobimo Management AG',
|
tenantCompany: 'Mobimo Management AG',
|
||||||
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
{
|
{
|
||||||
id: 'dinq-003',
|
id: 'dinq-003',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'INQUIRY',
|
||||||
propertyId: 'prop-sihlcity',
|
propertyId: 'prop-sihlcity',
|
||||||
tenantName: 'Admin User',
|
tenantName: 'Admin User',
|
||||||
tenantCompany: 'Mobimo Management AG',
|
tenantCompany: 'Mobimo Management AG',
|
||||||
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
{
|
{
|
||||||
id: 'dinq-004',
|
id: 'dinq-004',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'INQUIRY',
|
||||||
propertyId: 'prop-bahnhofzug',
|
propertyId: 'prop-bahnhofzug',
|
||||||
tenantName: 'Admin User',
|
tenantName: 'Admin User',
|
||||||
tenantCompany: 'Mobimo Management AG',
|
tenantCompany: 'Mobimo Management AG',
|
||||||
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
{
|
{
|
||||||
id: 'dinq-005',
|
id: 'dinq-005',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'INQUIRY',
|
||||||
propertyId: 'prop-dreispitz',
|
propertyId: 'prop-dreispitz',
|
||||||
tenantName: 'Admin User',
|
tenantName: 'Admin User',
|
||||||
tenantCompany: 'Mobimo Management AG',
|
tenantCompany: 'Mobimo Management AG',
|
||||||
@@ -238,4 +248,40 @@ export const mockDemandInquiries: Inquiry[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 6 — OFFER (Verwaltung → Nachfrager, proaktiv aus Latente Anfragen)
|
||||||
|
{
|
||||||
|
id: 'off-seed-001',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
tenantOrgId: 'org-mobimo',
|
||||||
|
kind: 'OFFER',
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
offeredPropertyIds: ['prop-001', 'prop-093'],
|
||||||
|
tenantName: 'Mobimo Management AG',
|
||||||
|
propertyManagerCompany: 'Wincasa AG',
|
||||||
|
propertyAddress: '2 Objekte im Angebot',
|
||||||
|
subject: 'Passende Büroflächen zu Ihrem Bedarf',
|
||||||
|
message:
|
||||||
|
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 1,
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2026-05-24T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-24T10:00:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'off-seed-001-1',
|
||||||
|
inquiryId: 'off-seed-001',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Wincasa AG',
|
||||||
|
subject: 'Passende Büroflächen zu Ihrem Bedarf',
|
||||||
|
body:
|
||||||
|
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'off-att-001', fileName: 'Angebot_Bueroflaechen_Zuerich.pdf', fileType: 'application/pdf', fileSize: 312500, generated: true },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-24T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
InputAdornment, Alert,
|
InputAdornment, Alert,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
||||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||||
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||||
@@ -18,10 +17,9 @@ import { useSessionStore } from '../../stores/sessionStore'
|
|||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FILTER_TABS = [
|
const FILTER_TABS = [
|
||||||
{ key: 'all', label: 'Alle' },
|
{ key: 'all', label: 'Alle' },
|
||||||
{ key: 'new', label: 'Neu' },
|
{ key: 'INQUIRY', label: 'Gesendet' },
|
||||||
{ key: 'in_progress', label: 'Aktiv' },
|
{ key: 'OFFER', label: 'Erhalten' },
|
||||||
{ key: 'answered', label: 'Beantwortet' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Anfragen page ─────────────────────────────────────────────────────────────
|
// ── Anfragen page ─────────────────────────────────────────────────────────────
|
||||||
@@ -36,20 +34,23 @@ export default function Anfragen() {
|
|||||||
const preselectedId = searchParams.get('inquiry')
|
const preselectedId = searchParams.get('inquiry')
|
||||||
|
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
|
||||||
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
const sendReply = useSendInquiryReply()
|
||||||
const allInquiries = [...storeInquiries, ...inquiries]
|
const markRead = useMarkThreadAsRead()
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
|
||||||
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
|
||||||
)
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('all')
|
const [kindFilter, setKindFilter] = useState('all')
|
||||||
const [replyText, setReplyText] = useState('')
|
const [replyText, setReplyText] = useState('')
|
||||||
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
|
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
|
||||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
||||||
const threadRef = useRef<HTMLDivElement>(null)
|
const threadRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Default-Auswahl: erste Konversation, sobald geladen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
|
||||||
|
}, [allInquiries, selectedId])
|
||||||
|
|
||||||
const filtered = allInquiries.filter(inq => {
|
const filtered = allInquiries.filter(inq => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const matchesSearch = !q ||
|
const matchesSearch = !q ||
|
||||||
@@ -59,12 +60,14 @@ export default function Anfragen() {
|
|||||||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
||||||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
||||||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
||||||
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
|
||||||
return matchesSearch && matchesStatus
|
return matchesSearch && matchesKind
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||||
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||||
|
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
|
||||||
|
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
|
||||||
|
|
||||||
// Pipeline link for currently selected inquiry
|
// Pipeline link for currently selected inquiry
|
||||||
const linkedPipelineItem = selected?.propertyId
|
const linkedPipelineItem = selected?.propertyId
|
||||||
@@ -86,7 +89,7 @@ export default function Anfragen() {
|
|||||||
|
|
||||||
function handleSelect(id: string) {
|
function handleSelect(id: string) {
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
|
markRead.mutate(id)
|
||||||
setMobileShowChat(true)
|
setMobileShowChat(true)
|
||||||
setKiAlert(null)
|
setKiAlert(null)
|
||||||
}
|
}
|
||||||
@@ -95,20 +98,10 @@ export default function Anfragen() {
|
|||||||
if (!replyText.trim() || !selectedId) return
|
if (!replyText.trim() || !selectedId) return
|
||||||
const text = replyText.trim()
|
const text = replyText.trim()
|
||||||
|
|
||||||
const msg: InquiryMessage = {
|
sendReply.mutate({
|
||||||
id: `msg-${Date.now()}`,
|
|
||||||
inquiryId: selectedId,
|
inquiryId: selectedId,
|
||||||
senderType: 'tenant',
|
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
|
||||||
senderName: 'Sie',
|
})
|
||||||
body: text,
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
setInquiries(prev => prev.map(i =>
|
|
||||||
i.id === selectedId
|
|
||||||
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
|
|
||||||
: i
|
|
||||||
))
|
|
||||||
setReplyText('')
|
setReplyText('')
|
||||||
|
|
||||||
// KI: detect stage transition from message content
|
// KI: detect stage transition from message content
|
||||||
@@ -160,13 +153,13 @@ export default function Anfragen() {
|
|||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
{FILTER_TABS.map(tab => (
|
{FILTER_TABS.map(tab => (
|
||||||
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
|
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
|
||||||
sx={{
|
sx={{
|
||||||
height: 22, fontSize: '0.7rem', cursor: 'pointer',
|
height: 22, fontSize: '0.7rem', cursor: 'pointer',
|
||||||
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||||
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||||
fontWeight: statusFilter === tab.key ? 700 : 400,
|
fontWeight: kindFilter === tab.key ? 700 : 400,
|
||||||
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -291,6 +284,27 @@ export default function Anfragen() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Angebot: enthaltene Objekte */}
|
||||||
|
{offeredProperties.length > 0 && (
|
||||||
|
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
|
||||||
|
Enthaltene Objekte ({offeredProperties.length})
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{offeredProperties.map(p => p && (
|
||||||
|
<Chip
|
||||||
|
key={p.id}
|
||||||
|
icon={<Building2 size={12} />}
|
||||||
|
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/demand/property/${p.id}`)}
|
||||||
|
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Thread */}
|
{/* Thread */}
|
||||||
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{selected.thread.map(msg => (
|
{selected.thread.map(msg => (
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default function Results() {
|
|||||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||||
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
||||||
)
|
)
|
||||||
|
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
|
||||||
|
(Number(localStorage.getItem('view-results-cols')) as 3 | 5 | 10) || 3
|
||||||
|
)
|
||||||
|
|
||||||
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
||||||
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
||||||
@@ -107,9 +110,11 @@ export default function Results() {
|
|||||||
futureCount={futureCount}
|
futureCount={futureCount}
|
||||||
view={view}
|
view={view}
|
||||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
||||||
|
columns={columns}
|
||||||
|
onColumnsChange={n => { setColumns(n); localStorage.setItem('view-results-cols', String(n)) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 4, pb: 12 }}>
|
||||||
|
|
||||||
{/* Aktive Suche */}
|
{/* Aktive Suche */}
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
@@ -169,7 +174,7 @@ export default function Results() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<UnifiedResultFeed results={sorted} view={view} />
|
<UnifiedResultFeed results={sorted} view={view} columns={columns} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useState } from 'react'
|
|||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||||
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
|
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
export default function Anfragencenter() {
|
export default function Anfragencenter() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
|
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
|
||||||
const [tab, setTab] = useState(initialTab)
|
const [tab, setTab] = useState(initialTab)
|
||||||
|
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||||
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab label="Aktive Anfragen" />
|
<Tab label="Aktive Anfragen" />
|
||||||
|
<Tab label="Gesendet" />
|
||||||
<Tab label="Latente Anfragen" />
|
<Tab label="Latente Anfragen" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
{tab === 0 && <ActiveInquiriesTab />}
|
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
|
||||||
{tab === 1 && <LatentInquiriesTab />}
|
{tab === 1 && (
|
||||||
|
<ActiveInquiriesTab
|
||||||
|
filters={{ organizationId: orgId, kind: 'OFFER' }}
|
||||||
|
emptyTitle="Keine gesendeten Angebote"
|
||||||
|
emptyDescription="Angebote aus den latenten Anfragen erscheinen hier, sobald Sie sie versenden."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 2 && <LatentInquiriesTab />}
|
||||||
</Box>
|
</Box>
|
||||||
<OfferWizard />
|
<OfferWizard />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
|
||||||
|
|
||||||
export interface InquiryFilters {
|
export interface InquiryFilters {
|
||||||
status?: InquiryStatus
|
status?: InquiryStatus
|
||||||
propertyId?: string
|
propertyId?: string
|
||||||
organizationId?: string
|
organizationId?: string // Supply-Perspektive
|
||||||
|
tenantOrgId?: string // Demand-Perspektive
|
||||||
|
kind?: InquiryKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Demand→Supply: neue Anfrage zu einem Objekt. */
|
||||||
|
export interface CreateInquiryInput {
|
||||||
|
organizationId: string // Supply-Org (Objekt-Eigentümer)
|
||||||
|
tenantOrgId: string // Demand-Org (Absender)
|
||||||
|
propertyId: string
|
||||||
|
needId?: string
|
||||||
|
tenantName: string
|
||||||
|
tenantCompany?: string
|
||||||
|
tenantEmail?: string
|
||||||
|
propertyAddress?: string
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
matchScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
|
||||||
|
export interface CreateOfferInput {
|
||||||
|
organizationId: string // Supply-Org (Absender)
|
||||||
|
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
|
||||||
|
needId: string
|
||||||
|
propertyId: string // primäres Objekt (Kompatibilität)
|
||||||
|
offeredPropertyIds: string[]
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
senderName: string // Verwaltungs-/Org-Name
|
||||||
|
recipientName?: string
|
||||||
|
attachments?: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInquiryProvider {
|
export interface IInquiryProvider {
|
||||||
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
||||||
getById(id: string): Promise<Inquiry | null>
|
getById(id: string): Promise<Inquiry | null>
|
||||||
|
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
|
||||||
|
createOffer(input: CreateOfferInput): Promise<Inquiry>
|
||||||
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||||
markThreadAsRead(id: string): Promise<Inquiry>
|
markThreadAsRead(id: string): Promise<Inquiry>
|
||||||
getUnreadCount(): Promise<number>
|
getUnreadCount(filters?: InquiryFilters): Promise<number>
|
||||||
addMessage(
|
addMessage(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||||
|
|||||||
@@ -1,20 +1,99 @@
|
|||||||
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
|
import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
|
||||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
||||||
import { mockInquiries } from '../mock-data/inquiries'
|
import { mockInquiries } from '../mock-data/inquiries'
|
||||||
|
import { mockDemandInquiries } from '../mock-data/demandInquiries'
|
||||||
|
|
||||||
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
|
// Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
|
||||||
|
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
|
||||||
|
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
|
||||||
|
|
||||||
|
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
|
||||||
|
let out = results
|
||||||
|
if (filters?.status) out = out.filter(i => i.status === filters.status)
|
||||||
|
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
|
||||||
|
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
|
||||||
|
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
|
||||||
|
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export const MockupInquiryProvider: IInquiryProvider = {
|
export const MockupInquiryProvider: IInquiryProvider = {
|
||||||
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
|
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
|
||||||
let results = [...store]
|
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
||||||
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
|
||||||
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
|
|
||||||
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
|
|
||||||
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
|
||||||
},
|
},
|
||||||
async getById(id: string): Promise<Inquiry | null> {
|
async getById(id: string): Promise<Inquiry | null> {
|
||||||
return store.find(i => i.id === id) ?? null
|
return store.find(i => i.id === id) ?? null
|
||||||
},
|
},
|
||||||
|
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
|
||||||
|
const inquiry: Inquiry = {
|
||||||
|
id,
|
||||||
|
kind: 'INQUIRY',
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
tenantOrgId: input.tenantOrgId,
|
||||||
|
propertyId: input.propertyId,
|
||||||
|
needId: input.needId,
|
||||||
|
tenantName: input.tenantName,
|
||||||
|
tenantCompany: input.tenantCompany,
|
||||||
|
tenantEmail: input.tenantEmail,
|
||||||
|
propertyAddress: input.propertyAddress,
|
||||||
|
subject: input.subject,
|
||||||
|
message: input.message,
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 1, // neu für die Verwaltung (Empfänger)
|
||||||
|
isRead: false,
|
||||||
|
matchScore: input.matchScore,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
thread: [{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
inquiryId: id,
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: input.tenantCompany ?? input.tenantName,
|
||||||
|
subject: input.subject,
|
||||||
|
body: input.message,
|
||||||
|
attachments: [],
|
||||||
|
createdAt: now,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
store.unshift(inquiry)
|
||||||
|
return inquiry
|
||||||
|
},
|
||||||
|
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = `off-${crypto.randomUUID().slice(0, 8)}`
|
||||||
|
const inquiry: Inquiry = {
|
||||||
|
id,
|
||||||
|
kind: 'OFFER',
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
tenantOrgId: input.tenantOrgId,
|
||||||
|
propertyId: input.propertyId,
|
||||||
|
offeredPropertyIds: input.offeredPropertyIds,
|
||||||
|
needId: input.needId,
|
||||||
|
tenantName: input.recipientName ?? '',
|
||||||
|
propertyManagerCompany: input.senderName,
|
||||||
|
subject: input.subject,
|
||||||
|
message: input.message,
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 1, // neu für den Nachfrager (Empfänger)
|
||||||
|
isRead: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
thread: [{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
inquiryId: id,
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: input.senderName,
|
||||||
|
subject: input.subject,
|
||||||
|
body: input.message,
|
||||||
|
attachments: input.attachments ?? [],
|
||||||
|
createdAt: now,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
store.unshift(inquiry)
|
||||||
|
return inquiry
|
||||||
|
},
|
||||||
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
|
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
|
||||||
const idx = store.findIndex(i => i.id === id)
|
const idx = store.findIndex(i => i.id === id)
|
||||||
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
||||||
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
|||||||
}
|
}
|
||||||
return store[idx]
|
return store[idx]
|
||||||
},
|
},
|
||||||
async getUnreadCount(): Promise<number> {
|
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
|
||||||
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||||
},
|
},
|
||||||
async addMessage(
|
async addMessage(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
|||||||
store[idx] = {
|
store[idx] = {
|
||||||
...store[idx],
|
...store[idx],
|
||||||
thread: [...store[idx].thread, message],
|
thread: [...store[idx].thread, message],
|
||||||
|
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
|
||||||
|
unreadCount: (store[idx].unreadCount ?? 0) + 1,
|
||||||
|
isRead: false,
|
||||||
|
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
|
||||||
updatedAt: message.createdAt,
|
updatedAt: message.createdAt,
|
||||||
}
|
}
|
||||||
return store[idx]
|
return store[idx]
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
|
|||||||
return getAllUnits().find(u => u.id === unitId) ?? null
|
return getAllUnits().find(u => u.id === unitId) ?? null
|
||||||
},
|
},
|
||||||
async update(unitId: string, data: Partial<PropertyUnit>) {
|
async update(unitId: string, data: Partial<PropertyUnit>) {
|
||||||
for (const prop of propertyStore) {
|
for (let i = 0; i < propertyStore.length; i++) {
|
||||||
|
const prop = propertyStore[i]
|
||||||
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
|
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
|
||||||
if (idx === -1) continue
|
if (idx === -1) continue
|
||||||
prop.units![idx] = { ...prop.units![idx], ...data }
|
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
|
||||||
return prop.units![idx]
|
// die Änderung via Structural-Sharing erkennt und neu rendert.
|
||||||
|
const nextUnits = [...prop.units!]
|
||||||
|
nextUnits[idx] = { ...nextUnits[idx], ...data }
|
||||||
|
propertyStore[i] = { ...prop, units: nextUnits }
|
||||||
|
return nextUnits[idx]
|
||||||
}
|
}
|
||||||
throw new Error(`Unit ${unitId} not found`)
|
throw new Error(`Unit ${unitId} not found`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -176,6 +176,27 @@ export interface FitOutAdvice {
|
|||||||
estimatedNetInvestment: string
|
estimatedNetInvestment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pre-market rent recommendation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PreMarketRentInput {
|
||||||
|
city: string
|
||||||
|
assetType: string
|
||||||
|
areaSqm: number
|
||||||
|
currentRentPerSqm: number
|
||||||
|
availableFrom?: string // ISO — Pre-Market liegt in der Zukunft
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreMarketRentRecommendation {
|
||||||
|
recommendedPerSqm: number
|
||||||
|
rangeMinPerSqm: number
|
||||||
|
rangeMaxPerSqm: number
|
||||||
|
verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises
|
||||||
|
deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis
|
||||||
|
drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend …
|
||||||
|
rationale: string
|
||||||
|
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
}
|
||||||
|
|
||||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||||
|
|
||||||
export interface CriteriaExtractionResult {
|
export interface CriteriaExtractionResult {
|
||||||
@@ -220,6 +241,9 @@ export interface IAIService {
|
|||||||
// Fit-out investment advice (demand side)
|
// Fit-out investment advice (demand side)
|
||||||
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
||||||
|
|
||||||
|
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
|
||||||
|
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
|
||||||
|
|
||||||
// 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[]>>
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { ServiceErrorCode } from '../../types'
|
import { ServiceErrorCode } from '../../types'
|
||||||
import { AppError } from '../../errors'
|
import { AppError } from '../../errors'
|
||||||
@@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
|||||||
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
||||||
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
||||||
import { MockAIService } from '../mock/MockAIService'
|
import { MockAIService } from '../mock/MockAIService'
|
||||||
|
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
|
|||||||
}, () => MockAIService.generateFitOutAdvice(input))
|
}, () => MockAIService.generateFitOutAdvice(input))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||||
|
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>> {
|
||||||
|
return withFallback('recommendPreMarketRent', async () => {
|
||||||
|
const intel = getCityIntelligence(input.city)
|
||||||
|
const comp = getMarketRent(input.city, input.assetType)
|
||||||
|
const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON:
|
||||||
|
{
|
||||||
|
"recommendedPerSqm": number,
|
||||||
|
"rangeMinPerSqm": number,
|
||||||
|
"rangeMaxPerSqm": number,
|
||||||
|
"verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS",
|
||||||
|
"deltaVsCurrentPct": number,
|
||||||
|
"drivers": ["kurze Treiber auf Deutsch"],
|
||||||
|
"rationale": "2-3 Sätze Begründung auf Deutsch",
|
||||||
|
"confidence": "LOW" | "MEDIUM" | "HIGH"
|
||||||
|
}`
|
||||||
|
const user = `Stadt: ${input.city}
|
||||||
|
Nutzung: ${input.assetType}
|
||||||
|
Fläche: ${input.areaSqm} m²
|
||||||
|
Heutiger Preis: CHF ${input.currentRentPerSqm}/m²
|
||||||
|
Vergleichsmiete (Median): ${comp ?? 'unbekannt'}
|
||||||
|
Leerstand: ${intel?.vacancyRatePct ?? '?'}%
|
||||||
|
Nachfrage: ${intel?.demandStrength ?? '?'}
|
||||||
|
Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
|
||||||
|
Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage`
|
||||||
|
const raw = await chat(system, user)
|
||||||
|
const json = extractJSON<PreMarketRentRecommendation>(raw)
|
||||||
|
if (!json || typeof json.recommendedPerSqm !== 'number') {
|
||||||
|
const fb = await MockAIService.recommendPreMarketRent(input)
|
||||||
|
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
|
||||||
|
}
|
||||||
|
return { data: json, provenance: makeProvenance('ai', false, true) }
|
||||||
|
}, () => MockAIService.recommendPreMarketRent(input))
|
||||||
|
},
|
||||||
|
|
||||||
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
||||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||||
return withFallback('extractCriteria', async () => {
|
return withFallback('extractCriteria', async () => {
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { mockProvenance } from '../IAIService'
|
import { mockProvenance } from '../IAIService'
|
||||||
import { aiTraceStore } from '../tracing'
|
import { aiTraceStore } from '../tracing'
|
||||||
import { mockParseNeed } from './needParser'
|
import { mockParseNeed } from './needParser'
|
||||||
import { buildComparisonSummary } from './compareBuilder'
|
import { buildComparisonSummary } from './compareBuilder'
|
||||||
import { buildMockDecisionBrief } from './decisionBrief'
|
import { buildMockDecisionBrief } from './decisionBrief'
|
||||||
|
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||||
|
|
||||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
@@ -338,6 +341,74 @@ export const MockAIService: IAIService = {
|
|||||||
return { data, provenance: mockProvenance() }
|
return { data, provenance: mockProvenance() }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||||
|
recommendPreMarketRent: (input: PreMarketRentInput) =>
|
||||||
|
traceMock('recommendPreMarketRent', async () => {
|
||||||
|
await delay(SIMULATED_DELAY.fast)
|
||||||
|
const intel = getCityIntelligence(input.city)
|
||||||
|
const comp = getMarketRent(input.city, input.assetType)
|
||||||
|
const current = input.currentRentPerSqm
|
||||||
|
const ASSET_LABELS: Record<string, string> = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' }
|
||||||
|
const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche'
|
||||||
|
|
||||||
|
// Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz
|
||||||
|
if (!intel || comp == null) {
|
||||||
|
const rec = Math.round(current * 1.02)
|
||||||
|
const data: PreMarketRentRecommendation = {
|
||||||
|
recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07),
|
||||||
|
verdict: 'FAIR', deltaVsCurrentPct: 0,
|
||||||
|
drivers: ['Keine regionalen Vergleichsdaten verfügbar'],
|
||||||
|
rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.',
|
||||||
|
confidence: 'LOW',
|
||||||
|
}
|
||||||
|
return { data, provenance: mockProvenance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
|
||||||
|
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
|
||||||
|
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
|
||||||
|
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
|
||||||
|
if (intel.vacancyRatePct < 2.5) adj += 0.04
|
||||||
|
else if (intel.vacancyRatePct < 4) adj += 0.02
|
||||||
|
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
|
||||||
|
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
|
||||||
|
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
|
||||||
|
if (intel.avgDaysOnMarket < 35) adj += 0.015
|
||||||
|
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
|
||||||
|
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
|
||||||
|
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
|
||||||
|
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: −10 % … +15 %
|
||||||
|
|
||||||
|
const recommended = Math.round(current * (1 + adj))
|
||||||
|
const rangeMin = Math.round(recommended * 0.95)
|
||||||
|
const rangeMax = Math.round(recommended * 1.05)
|
||||||
|
const deltaVsCurrentPct = Math.round(adj * 100)
|
||||||
|
const verdict: PreMarketRentRecommendation['verdict'] =
|
||||||
|
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
|
||||||
|
|
||||||
|
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
|
||||||
|
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
|
||||||
|
const drivers = [
|
||||||
|
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
|
||||||
|
demandLabel,
|
||||||
|
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
|
||||||
|
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
|
||||||
|
]
|
||||||
|
const verdictText =
|
||||||
|
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
|
||||||
|
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
|
||||||
|
: 'Heutiger Preis ist marktgerecht.'
|
||||||
|
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
|
||||||
|
const confidence: PreMarketRentRecommendation['confidence'] =
|
||||||
|
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
|
||||||
|
|
||||||
|
const data: PreMarketRentRecommendation = {
|
||||||
|
recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax,
|
||||||
|
verdict, deltaVsCurrentPct, drivers, rationale, confidence,
|
||||||
|
}
|
||||||
|
return { data, provenance: mockProvenance() }
|
||||||
|
}),
|
||||||
|
|
||||||
// Legacy methods
|
// Legacy methods
|
||||||
extractCriteria: (_input: string) =>
|
extractCriteria: (_input: string) =>
|
||||||
traceMock('extractCriteria', async () => ({
|
traceMock('extractCriteria', async () => ({
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||||
import type { CreateNeedInput } from '../../domain/need'
|
import type { CreateNeedInput, Need } from '../../domain/need'
|
||||||
|
|
||||||
const ASSET_LABELS_TEXT: Record<string, string> = {
|
const ASSET_LABELS_TEXT: Record<string, string> = {
|
||||||
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
|
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
|
||||||
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
|
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function needToParsedCriteria(need: Need): ParsedNeedCriteria {
|
||||||
|
return {
|
||||||
|
assetType: need.assetType,
|
||||||
|
areaRange: need.requiredArea,
|
||||||
|
preferredLocations: need.preferredLocations,
|
||||||
|
budgetRange: need.budgetRange,
|
||||||
|
timing: need.timing,
|
||||||
|
mustHaveCriteria: need.mustCriteriaText,
|
||||||
|
softFactors: need.softFactors,
|
||||||
|
requireGroundFloor: need.requireGroundFloor,
|
||||||
|
requiredFitOut: need.requiredFitOut,
|
||||||
|
requiredParkingMin: need.requiredParkingMin,
|
||||||
|
requireAirConditioning: need.requireAirConditioning,
|
||||||
|
requireLoadingDock: need.requireLoadingDock,
|
||||||
|
requireBarrierFree: need.requireBarrierFree,
|
||||||
|
minCeilingHeightM: need.minCeilingHeightM,
|
||||||
|
minContractDurationMonths: need.minContractDurationMonths,
|
||||||
|
searchRadius: need.searchRadius,
|
||||||
|
isAnonymous: need.isAnonymous,
|
||||||
|
requiresDivisibility: need.requiresDivisibility,
|
||||||
|
minDivisibleUnit: need.minDivisibleUnit,
|
||||||
|
fitOutBudgetMaxPerSqm: need.fitOutBudgetMaxPerSqm,
|
||||||
|
notes: need.notes,
|
||||||
|
companyName: need.companyName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function generateSummary(c: ParsedNeedCriteria): string {
|
export function generateSummary(c: ParsedNeedCriteria): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
|
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ export type {
|
|||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
FitOutAdvice,
|
FitOutAdvice,
|
||||||
FitOutAdviceInput,
|
FitOutAdviceInput,
|
||||||
|
PreMarketRentInput,
|
||||||
|
PreMarketRentRecommendation,
|
||||||
} from './ai/IAIService'
|
} from './ai/IAIService'
|
||||||
export { parseListingText } from './ai/mock/listingParser'
|
export { parseListingText } from './ai/mock/listingParser'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||||
import type { Inquiry, Attachment } from '../domain/inquiry'
|
import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
import { throwServiceError } from './errors'
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
|
|||||||
subject?: string
|
subject?: string
|
||||||
body: string
|
body: string
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
|
senderType?: InquiryMessage['senderType'] // default 'supply_user'
|
||||||
|
senderName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inquiryService = {
|
export const inquiryService = {
|
||||||
@@ -31,14 +33,32 @@ export const inquiryService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createInquiry(input: CreateInquiryInput): Promise<ItemResponse<Inquiry>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.createInquiry(input)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOffer(input: CreateOfferInput): Promise<ItemResponse<Inquiry>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.createOffer(input)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async sendInquiryReply(
|
async sendInquiryReply(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
payload: InquiryReplyPayload,
|
payload: InquiryReplyPayload,
|
||||||
): Promise<ItemResponse<Inquiry>> {
|
): Promise<ItemResponse<Inquiry>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.addMessage(inquiryId, {
|
const data = await provider.addMessage(inquiryId, {
|
||||||
senderType: 'supply_user',
|
senderType: payload.senderType ?? 'supply_user',
|
||||||
senderName: 'Wincasa AG',
|
senderName: payload.senderName ?? 'Wincasa AG',
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
body: payload.body,
|
body: payload.body,
|
||||||
attachments: payload.attachments ?? [],
|
attachments: payload.attachments ?? [],
|
||||||
@@ -58,9 +78,9 @@ export const inquiryService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUnreadCount(): Promise<ItemResponse<number>> {
|
async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.getUnreadCount()
|
const data = await provider.getUnreadCount(filters)
|
||||||
return { data }
|
return { data }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throwServiceError(err)
|
throwServiceError(err)
|
||||||
|
|||||||
@@ -87,12 +87,23 @@ function scoreProperty(
|
|||||||
return calculateScore(need, prop)
|
return calculateScore(need, prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und
|
||||||
|
// gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability).
|
||||||
|
const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE'])
|
||||||
|
|
||||||
|
function isExternalListing(prop: Property): boolean {
|
||||||
|
return prop.resultType === ResultType.VERIFIED_PORTFOLIO
|
||||||
|
&& !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
/** Generate matches for a single need against all properties and push them into matchStore. */
|
/** Generate matches for a single need against all properties and push them into matchStore. */
|
||||||
export function generateMatchesForNeed(need: Need): void {
|
export function generateMatchesForNeed(need: Need): void {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const MIN_SCORE = 22
|
const MIN_SCORE = 22
|
||||||
|
|
||||||
for (const prop of propertyStore) {
|
for (const prop of propertyStore) {
|
||||||
|
// Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen
|
||||||
|
if (isExternalListing(prop)) continue
|
||||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||||
|
|
||||||
if (hasExplicitUnits) {
|
if (hasExplicitUnits) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
|
|||||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
import { throwServiceError } from './errors'
|
import { throwServiceError } from './errors'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
|
||||||
const provider = MockupNeedProvider
|
const provider = MockupNeedProvider
|
||||||
|
|
||||||
@@ -25,7 +26,10 @@ export const needService = {
|
|||||||
},
|
},
|
||||||
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.create(input)
|
// Neues Suchprofil der Organisation des Erstellers zuordnen (sonst fällt es aus der
|
||||||
|
// org-gefilterten Liste — Aktive-Suche-Leiste zeigt sonst ein fremdes Profil)
|
||||||
|
const orgId = useSessionStore.getState().currentUser?.organizationId
|
||||||
|
const data = await provider.create({ ...input, organizationId: input.organizationId ?? orgId })
|
||||||
return { data }
|
return { data }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throwServiceError(err)
|
throwServiceError(err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
|
|
||||||
|
|
||||||
|
// Reine UI-State: der Anfrage-Dialog. Versendete Anfragen leben jetzt im Provider
|
||||||
|
// (MockupInquiryProvider) und werden via React Query gelesen — nicht mehr hier.
|
||||||
export interface PendingInquiry {
|
export interface PendingInquiry {
|
||||||
propertyTitle: string
|
propertyTitle: string
|
||||||
location: string
|
location: string
|
||||||
@@ -13,15 +14,10 @@ export interface PendingInquiry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface InquiryStore {
|
interface InquiryStore {
|
||||||
// Dialog state
|
|
||||||
dialogOpen: boolean
|
dialogOpen: boolean
|
||||||
pendingInquiry: PendingInquiry | null
|
pendingInquiry: PendingInquiry | null
|
||||||
openInquiryDialog: (item: PendingInquiry) => void
|
openInquiryDialog: (item: PendingInquiry) => void
|
||||||
closeInquiryDialog: () => void
|
closeInquiryDialog: () => void
|
||||||
|
|
||||||
// Sent inquiries (in-memory, persists for the session)
|
|
||||||
sentInquiries: Inquiry[]
|
|
||||||
addInquiry: (item: PendingInquiry, message: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInquiryStore = create<InquiryStore>((set) => ({
|
export const useInquiryStore = create<InquiryStore>((set) => ({
|
||||||
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
|
|||||||
pendingInquiry: null,
|
pendingInquiry: null,
|
||||||
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
|
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
|
||||||
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
|
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
|
||||||
|
|
||||||
sentInquiries: [],
|
|
||||||
addInquiry: (item, message) => {
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const msgId = `msg-sent-${Date.now()}`
|
|
||||||
const id = `sent-${Date.now()}`
|
|
||||||
|
|
||||||
const thread: InquiryMessage = {
|
|
||||||
id: msgId,
|
|
||||||
inquiryId: id,
|
|
||||||
senderType: 'tenant',
|
|
||||||
senderName: 'Admin User',
|
|
||||||
body: message,
|
|
||||||
attachments: [],
|
|
||||||
createdAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
const inquiry: Inquiry = {
|
|
||||||
id,
|
|
||||||
organizationId: 'org-wincasa',
|
|
||||||
propertyId: item.propertyId ?? 'unknown',
|
|
||||||
tenantName: 'Admin User',
|
|
||||||
tenantCompany: 'Mobimo Management AG',
|
|
||||||
tenantEmail: 'admin@ideal-sharing.ch',
|
|
||||||
propertyAddress: item.propertyTitle,
|
|
||||||
propertyManagerName: 'Verwalter',
|
|
||||||
propertyManagerCompany: '',
|
|
||||||
subject: 'Anfrage: ' + item.propertyTitle,
|
|
||||||
message,
|
|
||||||
status: 'new',
|
|
||||||
unreadCount: 0,
|
|
||||||
isRead: true,
|
|
||||||
matchScore: item.matchScore,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
thread: [thread],
|
|
||||||
}
|
|
||||||
|
|
||||||
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user