Compare commits
5 Commits
11de05025c
...
e95490eb72
| Author | SHA1 | Date | |
|---|---|---|---|
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd |
@@ -11,7 +11,9 @@
|
|||||||
"Bash(start http://localhost:5173)",
|
"Bash(start http://localhost:5173)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
"Bash(node scripts/check-tokens.js)"
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(echo \"EXIT_CODE:$?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)"
|
"Bash(git commit *)",
|
||||||
|
"Bash(node -e ' *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
|
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
|||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
const ICONS: Record<ResultType, React.ElementType> = {
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
MAISON_WORK: Building2,
|
MAISON_WORK: Building2,
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
FUTURE_AVAILABILITY: Sparkles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
|
|||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
|
|||||||
inq: Inquiry
|
inq: Inquiry
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
hasPipeline: boolean
|
hasPipeline: boolean
|
||||||
|
perspective?: 'demand' | 'supply'
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
|
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
|
||||||
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||||
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||||
|
|
||||||
|
const primaryTitle = perspective === 'demand'
|
||||||
|
? (inq.propertyAddress ?? inq.subject)
|
||||||
|
: inq.tenantName
|
||||||
|
|
||||||
|
const secondaryLine = perspective === 'demand'
|
||||||
|
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
|
||||||
|
: (inq.tenantCompany ?? '')
|
||||||
|
|
||||||
|
const lastMsgPrefix = (() => {
|
||||||
|
if (!lastMsg) return ''
|
||||||
|
if (perspective === 'demand') {
|
||||||
|
return lastMsg.senderType === 'tenant'
|
||||||
|
? 'Sie: '
|
||||||
|
: `${inq.propertyManagerName ?? 'Verwalter'}: `
|
||||||
|
}
|
||||||
|
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||||
{inq.tenantName}
|
{primaryTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||||
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{inq.tenantCompany && (
|
{secondaryLine && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||||
{inq.tenantCompany}
|
{secondaryLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||||
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
</Typography>
|
</Typography>
|
||||||
{lastMsg && (
|
{lastMsg && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
||||||
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
|
{lastMsgPrefix}
|
||||||
{lastMsg.body.split('\n')[0]}
|
{lastMsg.body.split('\n')[0]}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
|
|||||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
|
|||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
const TYPE_DOT: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
MAISON_WORK: '#0369a1',
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
@@ -19,12 +20,15 @@ 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 }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: 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()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', top: 10, left: 10,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
background: theme.gradient, borderRadius: '10px',
|
||||||
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
|
|||||||
@@ -1,119 +1,216 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||||
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
mb: 1.5,
|
||||||
mb: 1.5,
|
borderRadius: 2,
|
||||||
border: `2px solid ${borderColor}`,
|
overflow: 'hidden',
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
border: `2px solid ${borderColor}`,
|
||||||
transition: 'border-color 0.15s',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||||
}}
|
background: theme.cardBg,
|
||||||
>
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
transition: 'box-shadow 0.15s',
|
||||||
{vm.disclaimer && (
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
}}>
|
||||||
{vm.disclaimer}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
{/* ── Image strip ── */}
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||||
Manuelle Überprüfung erforderlich
|
{imageUrl ? (
|
||||||
</Alert>
|
<img
|
||||||
)}
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 120,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#7ba3bf" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
{/* Score badge */}
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Box sx={{
|
||||||
|
position: 'absolute', top: 8, left: 8,
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1, py: 0.25,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Title + location (max 2 lines) */}
|
{/* Asset type label */}
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
{overlayTypeLabel && (
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
<Box sx={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
px: 0.75, py: 0.5,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{vm.disclaimer && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Badges row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={rt.label} size="small"
|
||||||
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
|
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||||
|
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||||
|
)}
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||||
|
)}
|
||||||
|
{topRisk && (
|
||||||
|
<Chip
|
||||||
|
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||||
|
size="small" color="error" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title + location */}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||||
<MapPin size={13} color="#64748b" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{vm.locationLabel}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||||
variant="body2"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* Score breakdown */}
|
||||||
|
{vm.scoreBreakdown && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasons — all 3, same style as grid card */}
|
||||||
|
{vm.reasons.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top tradeoff */}
|
||||||
|
{vm.tradeoffs.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
|
{vm.tradeoffs[0].description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button size="small" variant="contained" onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick} disabled={a.disabled}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
|
||||||
|
|
||||||
{/* Score formula — always visible */}
|
|
||||||
{vm.scoreBreakdown && (
|
|
||||||
<Box sx={{ mb: 1.25 }}>
|
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top reason only */}
|
|
||||||
{vm.reasons.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
|
||||||
{vm.tradeoffs.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
|
||||||
<MatchDataQualitySummary
|
|
||||||
dataQualityScore={vm.dataQualityScore}
|
|
||||||
missingData={vm.missingData}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { FileImage } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface FloorPlan {
|
||||||
|
url: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: FloorPlan[]
|
||||||
|
mb?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanSection({ plans, mb }: Props) {
|
||||||
|
if (plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{plans.map((p, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{p.label && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||||
|
{p.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={p.url}
|
||||||
|
alt={p.label ?? 'Grundriss'}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'contain',
|
||||||
|
maxHeight: 480,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
|
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
|
||||||
|
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryQuickDialog() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||||
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
|
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||||
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogOpen && pendingInquiry) {
|
||||||
|
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
|
||||||
|
}
|
||||||
|
}, [dialogOpen, pendingInquiry])
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!pendingInquiry) return
|
||||||
|
addInquiry(pendingInquiry, message)
|
||||||
|
if (pendingInquiry.pipelineItemId) {
|
||||||
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
|
}
|
||||||
|
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||||
|
closeInquiryDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingInquiry) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Send size={16} />
|
||||||
|
Anfrage senden
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ pt: 0 }}>
|
||||||
|
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{pendingInquiry.location}
|
||||||
|
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
|
||||||
|
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
|
||||||
|
{' · '}Match {pendingInquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
|
||||||
|
Der Text kann vor dem Absenden angepasst werden.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||||
|
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!message.trim()}
|
||||||
|
startIcon={<Send size={14} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||||
|
|
||||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grundriss */}
|
||||||
|
{(() => {
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
if (matchedUnit?.floorPlanUrl) {
|
||||||
|
const lbl = matchedUnit.unitLabel
|
||||||
|
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||||
|
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||||
|
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||||
|
} else {
|
||||||
|
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.dataQuality.lastVerifiedAt && (
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
import type { Match, NextBestAction } from '../../domain/match'
|
import type { Match, NextBestAction } from '../../domain/match'
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
|
||||||
HIGH: 'contained',
|
|
||||||
MEDIUM: 'outlined',
|
|
||||||
LOW: 'outlined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
onCompare?: () => void
|
onCompare?: () => void
|
||||||
onShortlist?: () => void
|
onPipeline?: () => void
|
||||||
onReject?: () => void
|
onInquire?: () => void
|
||||||
onReview?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||||
)
|
)
|
||||||
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||||
|
|
||||||
{engineActions.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
|
||||||
{engineActions.map((action, i) => (
|
|
||||||
<Box key={i}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
variant={PRIORITY_COLOR[action.priority]}
|
|
||||||
sx={
|
|
||||||
action.priority === 'HIGH'
|
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
|
||||||
: { justifyContent: 'flex-start' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
{action.description && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
|
|
||||||
{action.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Standard actions */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
{onShortlist && (
|
{onInquire && (
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
<Button
|
||||||
Zur Shortlist hinzufügen
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<MessageSquare size={14} />}
|
||||||
|
onClick={onInquire}
|
||||||
|
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{engineActions.length > 0 && engineActions.map((action, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
{action.description && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
|
||||||
|
{action.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onPipeline && (
|
||||||
|
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||||
|
Zur Pipeline hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
Zum Vergleich hinzufügen
|
Zum Vergleich hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReview && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
|
||||||
Zur Überprüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
|
||||||
Match ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import {
|
import {
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
UnitRow,
|
UnitRow,
|
||||||
} from './MatchDetailPropertyDetails'
|
} from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
interface PropertyDetailPublicSectionsProps {
|
interface PropertyDetailPublicSectionsProps {
|
||||||
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Grundriss ── */}
|
||||||
|
{(() => {
|
||||||
|
const allUnits = property.units ?? []
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} mb={2} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Beschreibung ── */}
|
{/* ── Beschreibung ── */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
@@ -270,7 +284,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floorPlanUrl: string
|
||||||
|
onFloorPlanUrlChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Grundriss-URL"
|
||||||
|
value={floorPlanUrl}
|
||||||
|
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
{floorPlanUrl && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={floorPlanUrl}
|
||||||
|
alt="Grundriss Vorschau"
|
||||||
|
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
|||||||
export { ImageUrlSection } from './ImageUrlSection'
|
export { ImageUrlSection } from './ImageUrlSection'
|
||||||
export { ContactSection } from './ContactSection'
|
export { ContactSection } from './ContactSection'
|
||||||
export { CreatedScreen } from './CreatedScreen'
|
export { CreatedScreen } from './CreatedScreen'
|
||||||
|
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import type { PipelineItem } from '../../domain/pipeline'
|
import type { PipelineItem } from '../../domain/pipeline'
|
||||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
import { detailPath } from './pipelineUtils'
|
import { detailPath } from './pipelineUtils'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
|
||||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
|||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||||
const path = detailPath(item)
|
const path = detailPath(item)
|
||||||
|
|
||||||
const style = !isDragOverlay ? {
|
const style = !isDragOverlay ? {
|
||||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip title="Objekt öffnen">
|
<Tooltip title="Match öffnen">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
|||||||
{item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Anfrage / Konversation */}
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MessagesSquare size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Konversation
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Send size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.propertyAddress ?? item.location,
|
||||||
|
areaLabel: item.areaLabel,
|
||||||
|
rentLabel: item.rentLabel,
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
pipelineItemId: item.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
|
onCardChatClick,
|
||||||
isOver,
|
isOver,
|
||||||
}: {
|
}: {
|
||||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (item: PipelineItem) => void
|
onSelect: (item: PipelineItem) => void
|
||||||
onChatClick: (inquiryId: string) => void
|
onChatClick: (inquiryId: string) => void
|
||||||
|
onCardChatClick?: (item: PipelineItem) => void
|
||||||
isOver: boolean
|
isOver: boolean
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={item.id === selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
onChatClick={
|
||||||
|
item.inquiryId
|
||||||
|
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
|
||||||
|
: onCardChatClick
|
||||||
|
? (e) => { e.stopPropagation(); onCardChatClick(item) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
|||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
||||||
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
|
||||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
|
|||||||
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
||||||
|
|
||||||
export function detailPath(item: PipelineItem): string | null {
|
export function detailPath(item: PipelineItem): string | null {
|
||||||
// propertyId is always stable across sessions — prefer it
|
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
|
||||||
|
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
|
||||||
|
if (isStableMatchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
||||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
|
||||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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'
|
||||||
@@ -97,6 +98,17 @@ 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}
|
||||||
@@ -104,9 +116,25 @@ 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MatchCardCompact vm={vm} />
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? result.property.images?.[0]
|
||||||
|
: undefined
|
||||||
|
const listOverlay = 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 <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace placeholder with Google Maps Static API:
|
|
||||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
|
||||||
|
|
||||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||||
@@ -24,7 +23,7 @@ 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 }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, 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
|
||||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom-left overlay: type + location */}
|
||||||
|
{(overlayTypeLabel || overlayLocationLabel) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.68rem', fontWeight: 700, color: 'white',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
|
||||||
|
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{overlayLocationLabel && (
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
|
||||||
|
{overlayLocationLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Google Maps button */}
|
{/* Google Maps button */}
|
||||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
|
|||||||
matchId: item.resultId,
|
matchId: item.resultId,
|
||||||
needId: '',
|
needId: '',
|
||||||
matchScore: item.matchScore,
|
matchScore: item.matchScore,
|
||||||
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
||||||
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { ShortlistItem } from '../../domain/shortlist'
|
|||||||
|
|
||||||
const RESULT_TYPE_LABEL: Record<string, string> = {
|
const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Portfolio',
|
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||||
EXTERNAL_MARKET: 'Direkt',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Signal',
|
FUTURE_AVAILABILITY: 'Signal',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
|||||||
GASTRO: 'Gastro',
|
GASTRO: 'Gastro',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
UNKNOWN: 'Unbekannt',
|
UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
return labels[type] ?? type
|
return labels[type] ?? type
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
|
|||||||
// ── Result Types ──────────────────────────────────────────────────────────────
|
// ── Result Types ──────────────────────────────────────────────────────────────
|
||||||
export const ResultType = {
|
export const ResultType = {
|
||||||
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
||||||
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
|
|
||||||
MAISON_WORK: 'MAISON_WORK',
|
MAISON_WORK: 'MAISON_WORK',
|
||||||
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface Inquiry {
|
|||||||
tenantName: string
|
tenantName: string
|
||||||
tenantCompany?: string
|
tenantCompany?: string
|
||||||
tenantEmail?: string
|
tenantEmail?: string
|
||||||
|
propertyManagerName?: string
|
||||||
|
propertyManagerCompany?: string
|
||||||
|
propertyAddress?: string
|
||||||
subject: string
|
subject: string
|
||||||
message: string
|
message: string
|
||||||
status?: InquiryStatus
|
status?: InquiryStatus
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
export type PipelineStage = 'SAVED' | 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
export type PipelineStage = 'SAVED' | 'CONTACTED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
||||||
|
|
||||||
export interface PipelineItem {
|
export interface PipelineItem {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
location: string
|
location: string
|
||||||
matchScore: number
|
matchScore: number
|
||||||
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||||
stage: PipelineStage
|
stage: PipelineStage
|
||||||
// Source match reference — used for navigating back to the detail page
|
// Source match reference — used for navigating back to the detail page
|
||||||
matchId?: string
|
matchId?: string
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface Property {
|
|||||||
riskLevel?: RiskLevel
|
riskLevel?: RiskLevel
|
||||||
description?: string
|
description?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
|
floorPlanUrl?: string
|
||||||
|
|
||||||
propertyNumber?: string
|
propertyNumber?: string
|
||||||
units?: PropertyUnit[]
|
units?: PropertyUnit[]
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
|
|||||||
export const CONFIDENCE_MODIFIER = {
|
export const CONFIDENCE_MODIFIER = {
|
||||||
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
||||||
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
||||||
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
|
|
||||||
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
||||||
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
||||||
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
||||||
resultType: 'VERIFIED_PORTFOLIO'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
|
||||||
property: Property
|
|
||||||
match: Match
|
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalMarketResult extends UnifiedResultBase {
|
|
||||||
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
|
||||||
property: Property
|
property: Property
|
||||||
match: Match
|
match: Match
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
unit?: PropertyUnit // specific unit this match refers to
|
||||||
@@ -38,5 +31,4 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
|||||||
|
|
||||||
export type UnifiedMatchResult =
|
export type UnifiedMatchResult =
|
||||||
| VerifiedPortfolioResult
|
| VerifiedPortfolioResult
|
||||||
| ExternalMarketResult
|
|
||||||
| FutureAvailabilityResult
|
| FutureAvailabilityResult
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PropertyUnit {
|
|||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
leaseTerm?: string
|
leaseTerm?: string
|
||||||
leaseEndDate?: string
|
leaseEndDate?: string
|
||||||
|
floorPlanUrl?: string // optional floor plan image
|
||||||
// Flexible letting (Teilfläche)
|
// Flexible letting (Teilfläche)
|
||||||
isFlexible?: boolean
|
isFlexible?: boolean
|
||||||
minLettableSqm?: number
|
minLettableSqm?: number
|
||||||
|
|||||||
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
|
|||||||
expect(ranked[0].propertyId).toBe('high')
|
expect(ranked[0].propertyId).toBe('high')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => {
|
it('breaks ties by result type: VERIFIED_PORTFOLIO and MAISON_WORK ranked above FUTURE_AVAILABILITY', () => {
|
||||||
// Build one of each type but force identical score by using same property body
|
// Build one of each type but force identical score by using same property body
|
||||||
const prop = makeProperty()
|
const prop = makeProperty()
|
||||||
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 })
|
const future = buildFullMatch(need, { ...prop, id: 'f', resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.85 })
|
||||||
|
|
||||||
// If scores differ, force them equal for a clean tiebreak test
|
// Force same score for a clean tiebreak test
|
||||||
if (verified.matchScore !== external.matchScore) {
|
verified.matchScore = 75
|
||||||
const lower = Math.min(verified.matchScore, external.matchScore)
|
future.matchScore = 75
|
||||||
verified.matchScore = lower
|
|
||||||
external.matchScore = lower
|
|
||||||
}
|
|
||||||
|
|
||||||
const ranked = rankMatches([external, verified])
|
const ranked = rankMatches([future, verified])
|
||||||
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
|
|||||||
expect(calcConfidenceModifier(p)).toBe(0)
|
expect(calcConfidenceModifier(p)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns -3 for external market results', () => {
|
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
|
|
||||||
expect(calcConfidenceModifier(p)).toBe(-3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns -2 for Maison Work results', () => {
|
it('returns -2 for Maison Work results', () => {
|
||||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-2)
|
expect(calcConfidenceModifier(p)).toBe(-2)
|
||||||
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
||||||
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
|
// MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-13)
|
expect(calcConfidenceModifier(p)).toBe(-12)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
||||||
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
|
|||||||
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => {
|
it('scores a weak match (wrong city, over budget, poor DQ, low confidence) below 50', () => {
|
||||||
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
||||||
const prop = makeProperty({
|
const prop = makeProperty({
|
||||||
location: { city: 'Basel', country: 'CH' },
|
location: { city: 'Basel', country: 'CH' },
|
||||||
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
||||||
dataQuality: {
|
dataQuality: {
|
||||||
score: 0.30, // CRITICAL → -15
|
score: 0.30, // CRITICAL → -15
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe('softFactorEnrichmentService — score range invariant (all keys, 0–1
|
|||||||
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
||||||
it('returns identical estimate for same location regardless of resultType', () => {
|
it('returns identical estimate for same location regardless of resultType', () => {
|
||||||
// The enrichment service is location-only — resultType is irrelevant.
|
// The enrichment service is location-only — resultType is irrelevant.
|
||||||
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
|
// FUTURE_AVAILABILITY (pre-market) and VERIFIED_PORTFOLIO should produce the same soft factor score.
|
||||||
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
||||||
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
||||||
|
|
||||||
@@ -141,16 +141,16 @@ describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
|||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||||
})
|
})
|
||||||
const marketResult = makeProperty({
|
const portfolioResult = makeProperty({
|
||||||
location: sharedLocation,
|
location: sharedLocation,
|
||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
})
|
})
|
||||||
|
|
||||||
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
||||||
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
|
const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
|
||||||
expect(futureEst?.score).toBe(marketEst?.score)
|
expect(futureEst?.score).toBe(portfolioEst?.score)
|
||||||
expect(futureEst?.label).toBe(marketEst?.label)
|
expect(futureEst?.label).toBe(portfolioEst?.label)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
||||||
|
|||||||
@@ -191,10 +191,10 @@ export function rankMatches(matches: Match[]): Match[] {
|
|||||||
return [...matches].sort((a, b) => {
|
return [...matches].sort((a, b) => {
|
||||||
// Primary: matchScore descending
|
// Primary: matchScore descending
|
||||||
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
||||||
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
|
// Secondary: VERIFIED / MAISON_WORK > FUTURE
|
||||||
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
|
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
|
||||||
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
if (aOrder !== bOrder) return aOrder - bOrder
|
if (aOrder !== bOrder) return aOrder - bOrder
|
||||||
// Tertiary: higher confidence first
|
// Tertiary: higher confidence first
|
||||||
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
||||||
|
|||||||
@@ -499,8 +499,6 @@ export function calcConfidenceModifier(property: Property): number {
|
|||||||
let mod = 0
|
let mod = 0
|
||||||
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
||||||
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
||||||
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
|
|
||||||
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
|
|
||||||
} else if (property.resultType === ResultType.MAISON_WORK) {
|
} else if (property.resultType === ResultType.MAISON_WORK) {
|
||||||
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
||||||
} else {
|
} else {
|
||||||
@@ -586,17 +584,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
]
|
]
|
||||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||||
|
|
||||||
// ── Modifiers: data quality and confidence adjust the final score ─────────
|
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||||||
// These are intentionally applied after the hard/soft weighted sum so that
|
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||||||
// a low-confidence or poor-data property can be penalised without distorting
|
|
||||||
// the individual criterion breakdown.
|
|
||||||
const dataQualityModifier = calcDataQualityModifier(property)
|
|
||||||
const confidenceModifier = calcConfidenceModifier(property)
|
|
||||||
|
|
||||||
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
|
|
||||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
|
||||||
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
|
||||||
+ dataQualityModifier + confidenceModifier
|
|
||||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||||
|
|
||||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||||
@@ -624,8 +613,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
finalScore,
|
finalScore,
|
||||||
hardMatchScore,
|
hardMatchScore,
|
||||||
softFactorScore,
|
softFactorScore,
|
||||||
dataQualityModifier,
|
dataQualityModifier: 0,
|
||||||
confidenceModifier,
|
confidenceModifier: 0,
|
||||||
positiveFactors,
|
positiveFactors,
|
||||||
negativeFactors,
|
negativeFactors,
|
||||||
allHardFactors: hardFactors,
|
allHardFactors: hardFactors,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface NewListingFormState {
|
|||||||
// Images
|
// Images
|
||||||
images: string[]
|
images: string[]
|
||||||
imageInput: string
|
imageInput: string
|
||||||
|
floorPlanUrl: string
|
||||||
// AI
|
// AI
|
||||||
aiText: string
|
aiText: string
|
||||||
aiApplied: boolean
|
aiApplied: boolean
|
||||||
@@ -82,6 +83,7 @@ export interface NewListingFormHandlers {
|
|||||||
setContactEmail: (v: string) => void
|
setContactEmail: (v: string) => void
|
||||||
setContactPhone: (v: string) => void
|
setContactPhone: (v: string) => void
|
||||||
setImageInput: (v: string) => void
|
setImageInput: (v: string) => void
|
||||||
|
setFloorPlanUrl: (v: string) => void
|
||||||
setAiText: (v: string) => void
|
setAiText: (v: string) => void
|
||||||
addImage: () => void
|
addImage: () => void
|
||||||
removeImage: (index: number) => void
|
removeImage: (index: number) => void
|
||||||
@@ -113,6 +115,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [imageInput, setImageInput] = useState('')
|
const [imageInput, setImageInput] = useState('')
|
||||||
|
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||||
const [aiText, setAiText] = useState('')
|
const [aiText, setAiText] = useState('')
|
||||||
const [aiApplied, setAiApplied] = useState(false)
|
const [aiApplied, setAiApplied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -157,7 +160,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||||
availableFrom, description, softLevels,
|
availableFrom, description, softLevels,
|
||||||
floor, fitOut, parking, ceilingHeight, images,
|
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onSuccess: () => setCreated(true),
|
onSuccess: () => setCreated(true),
|
||||||
@@ -173,7 +176,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||||
setSoftLevels(emptySoftLevels())
|
setSoftLevels(emptySoftLevels())
|
||||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||||
setImages([]); setImageInput('')
|
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||||
setAiText(''); setAiApplied(false)
|
setAiText(''); setAiApplied(false)
|
||||||
setCreated(false); setError(null)
|
setCreated(false); setError(null)
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
street, houseNumber, postalCode, city,
|
street, houseNumber, postalCode, city,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||||
contactName, contactEmail, contactPhone,
|
contactName, contactEmail, contactPhone,
|
||||||
images, imageInput, aiText, aiApplied,
|
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||||
error, created,
|
error, created,
|
||||||
aiParsing: parseListingMutation.isPending,
|
aiParsing: parseListingMutation.isPending,
|
||||||
submitting: createProperty.isPending,
|
submitting: createProperty.isPending,
|
||||||
@@ -193,7 +196,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setSoftLevel,
|
setSoftLevel,
|
||||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||||
setContactName, setContactEmail, setContactPhone,
|
setContactName, setContactEmail, setContactPhone,
|
||||||
setImageInput, setAiText,
|
setImageInput, setFloorPlanUrl, setAiText,
|
||||||
addImage, removeImage,
|
addImage, removeImage,
|
||||||
handleAiParse, handleSubmit, resetForm,
|
handleAiParse, handleSubmit, resetForm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
|||||||
import type {
|
import type {
|
||||||
UnifiedMatchResult,
|
UnifiedMatchResult,
|
||||||
VerifiedPortfolioResult,
|
VerifiedPortfolioResult,
|
||||||
ExternalMarketResult,
|
|
||||||
FutureAvailabilityResult,
|
FutureAvailabilityResult,
|
||||||
} from '../domain/unifiedResult'
|
} from '../domain/unifiedResult'
|
||||||
|
|
||||||
export function useUnifiedResults(needId?: string) {
|
export function useUnifiedResults(needId?: string) {
|
||||||
const allMatchesQuery = useMatches()
|
|
||||||
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
||||||
const propertiesQuery = useProperties()
|
const propertiesQuery = useProperties()
|
||||||
const signalsQuery = useFutureSignals()
|
const signalsQuery = useFutureSignals()
|
||||||
|
|
||||||
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
|
// Always scope to a specific need — showing cross-need results causes duplicates per property
|
||||||
|
const matchesQuery = needMatchesQuery
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
||||||
@@ -64,24 +63,11 @@ export function useUnifiedResults(needId?: string) {
|
|||||||
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
|
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
|
|
||||||
const result: ExternalMarketResult = {
|
|
||||||
matchId: match.id,
|
|
||||||
needId: match.needId,
|
|
||||||
matchScore: match.matchScore,
|
|
||||||
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
|
||||||
property,
|
|
||||||
match,
|
|
||||||
unit: matchUnit,
|
|
||||||
}
|
|
||||||
return [result]
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: VerifiedPortfolioResult = {
|
const result: VerifiedPortfolioResult = {
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
needId: match.needId,
|
needId: match.needId,
|
||||||
matchScore: match.matchScore,
|
matchScore: match.matchScore,
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: rt as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
property,
|
property,
|
||||||
match,
|
match,
|
||||||
unit: matchUnit,
|
unit: matchUnit,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
RETAIL: 'Retail',
|
RETAIL: 'Retail',
|
||||||
GASTRO: 'Gastronomie',
|
GASTRO: 'Gastronomie',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,6 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
// Result type display labels
|
// Result type display labels
|
||||||
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Plattform',
|
VERIFIED_PORTFOLIO: 'Plattform',
|
||||||
EXTERNAL_MARKET: 'Plattform',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
|
|||||||
export const DS_COLORS = {
|
export const DS_COLORS = {
|
||||||
resultType: {
|
resultType: {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
||||||
},
|
},
|
||||||
@@ -199,7 +198,6 @@ export function scoreToDataQualityLevel(score: number): DataQualityLevel {
|
|||||||
|
|
||||||
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
||||||
EXTERNAL_MARKET: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export function canViewProperty(
|
|||||||
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
|
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
|
||||||
return property.organizationId === user.organizationId
|
return property.organizationId === user.organizationId
|
||||||
}
|
}
|
||||||
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
|
|
||||||
if (property.resultType === ResultType.MAISON_WORK) return true
|
if (property.resultType === ResultType.MAISON_WORK) return true
|
||||||
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
||||||
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
|
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { AssetType } from '../domain/enums'
|
||||||
|
|
||||||
|
// ── Image pools (curated Unsplash IDs, all w=800&h=400&fit=crop) ──────────────
|
||||||
|
// Rule: ALL images are interior shots — no exteriors, no city skylines, no landscapes.
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const OFFICE_IDS = [
|
||||||
|
'photo-1497366858526-0766c4080517', // premium reception & lobby
|
||||||
|
'photo-1497366811353-6870744d04b2', // floor-to-ceiling glass, elegant interior
|
||||||
|
'photo-1497366216548-37526070297c', // clean open-plan office
|
||||||
|
'photo-1454165804606-c3d57bc86b40', // corporate meeting / workspace
|
||||||
|
'photo-1568992687947-868a62a9f521', // contemporary office with plants
|
||||||
|
'photo-1572025442646-866d16c84a54', // bright modern office
|
||||||
|
'photo-1553028826-f4804a6dba3b', // open workspace, natural light
|
||||||
|
'photo-1534536281715-e28d76689b4d', // converted warehouse loft
|
||||||
|
'photo-1556761175-5973dc0f32e7', // industrial co-working, exposed concrete
|
||||||
|
'photo-1541746972996-4e0b0f43e02a', // creative loft workspace
|
||||||
|
'photo-1515187029135-18ee286d815b', // brick + open ceiling office
|
||||||
|
'photo-1504384308090-c894fdcc538d', // industrial-chic open space
|
||||||
|
]
|
||||||
|
|
||||||
|
// Rotate so each sub-pool maps the same sequential index to a different photo
|
||||||
|
const rotate = <T>(arr: T[], n: number): T[] => [...arr.slice(n), ...arr.slice(0, n)]
|
||||||
|
|
||||||
|
const POOL = {
|
||||||
|
office_premium: rotate(OFFICE_IDS, 0),
|
||||||
|
office_modern: rotate(OFFICE_IDS, 4),
|
||||||
|
office_industrial: rotate(OFFICE_IDS, 8),
|
||||||
|
logistics: [
|
||||||
|
'photo-1586528116311-ad8dd3c8310d',
|
||||||
|
'photo-1553413077-190dd305871c',
|
||||||
|
'photo-1587293852726-70cfa4d5f99f',
|
||||||
|
'photo-1566932769119-25a49e6b5c6d',
|
||||||
|
'photo-1474396651759-b49538a26a2d',
|
||||||
|
],
|
||||||
|
retail: [
|
||||||
|
'photo-1441986300917-64674bd600d8',
|
||||||
|
'photo-1567958451986-2de427a4a0be',
|
||||||
|
'photo-1555529669-e69e7aa0ba9a',
|
||||||
|
'photo-1604719312566-8912e9227c6a',
|
||||||
|
'photo-1555396273-367ea4eb4db5',
|
||||||
|
'photo-1472851294608-062f824d29cc',
|
||||||
|
'photo-1483985988355-763728e1802b',
|
||||||
|
'photo-1445205170230-053b83016050',
|
||||||
|
],
|
||||||
|
gastro: [
|
||||||
|
'photo-1414235077428-338989a2e8c0',
|
||||||
|
'photo-1517248135467-4c7edcad34c4',
|
||||||
|
'photo-1544148103-0773bf10d330',
|
||||||
|
'photo-1559339352-11d035aa65ce',
|
||||||
|
'photo-1466978913421-dad2ebd01d17',
|
||||||
|
'photo-1521017432531-fbd92d768814',
|
||||||
|
],
|
||||||
|
light_industrial: [
|
||||||
|
'photo-1565515636339-5de8c80eba38',
|
||||||
|
'photo-1581091226825-a6a2a5aee158',
|
||||||
|
'photo-1571008887538-b36bb32f4571',
|
||||||
|
'photo-1504307651254-35680f356dfd',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = 'https://images.unsplash.com/'
|
||||||
|
const PARAMS = '?w=800&h=400&fit=crop'
|
||||||
|
|
||||||
|
// Sequential pick — guaranteed unique until pool wraps (used at init time in mock data)
|
||||||
|
function pickAt(pool: string[], index: number): string {
|
||||||
|
return `${BASE}${pool[index % pool.length]}${PARAMS}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── District sets (exported so properties.ts can replicate the pool-key logic) ──
|
||||||
|
|
||||||
|
export const PREMIUM_DISTRICTS = new Set([
|
||||||
|
'innenstadt', 'kreis 1', 'altstadt', 'seefeld', 'kreis 8',
|
||||||
|
'city', 'zürich city', 'bern innenstadt', 'zug innenstadt',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const INDUSTRIAL_DISTRICTS = new Set([
|
||||||
|
'zürich-west', 'zürich west', 'west', 'binz', 'zürich-binz',
|
||||||
|
'altstetten', 'hürlimann', 'technopark', 'industrial', 'industriezone',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Pool-key helper (exported so properties.ts can count per pool) ─────────────
|
||||||
|
|
||||||
|
export type PoolKey = keyof typeof POOL
|
||||||
|
|
||||||
|
export function getPoolKey(assetType: string | undefined, district: string, prestige: number): PoolKey {
|
||||||
|
switch (assetType) {
|
||||||
|
case AssetType.LOGISTICS: return 'logistics'
|
||||||
|
case AssetType.RETAIL: return 'retail'
|
||||||
|
case AssetType.GASTRO: return 'gastro'
|
||||||
|
case AssetType.LIGHT_INDUSTRIAL:
|
||||||
|
case AssetType.PRODUCTION: return 'light_industrial'
|
||||||
|
default: {
|
||||||
|
if (prestige >= 78 || PREMIUM_DISTRICTS.has(district)) return 'office_premium'
|
||||||
|
if (INDUSTRIAL_DISTRICTS.has(district)) return 'office_industrial'
|
||||||
|
return 'office_modern'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main resolver ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImageResolverParams {
|
||||||
|
assetType?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
prestige?: number
|
||||||
|
floorLevel?: number
|
||||||
|
propertyId?: string
|
||||||
|
poolIndex?: number // sequential index within pool — set by properties.ts forEach for duplicate-free assignment
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePropertyImage(p: ImageResolverParams): string {
|
||||||
|
const district = (p.district ?? '').toLowerCase()
|
||||||
|
const prestige = p.prestige ?? 60
|
||||||
|
const key = getPoolKey(p.assetType, district, prestige)
|
||||||
|
const pool = POOL[key]
|
||||||
|
// Sequential index (set at mock-data init) guarantees no duplicates within a pool
|
||||||
|
if (p.poolIndex !== undefined) return pickAt(pool, p.poolIndex)
|
||||||
|
// Fallback: hash-based (used when poolIndex is unavailable, e.g. runtime calls)
|
||||||
|
let h = 0
|
||||||
|
const seed = p.propertyId ?? `${p.city}${p.district}${p.assetType}`
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
|
||||||
|
return pickAt(pool, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image overlay label ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ASSET_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[AssetType.OFFICE]: 'Bürofläche',
|
||||||
|
[AssetType.RETAIL]: 'Retail EG',
|
||||||
|
[AssetType.GASTRO]: 'Gastrofläche',
|
||||||
|
[AssetType.LIGHT_INDUSTRIAL]: 'Gewerbe',
|
||||||
|
[AssetType.LOGISTICS]: 'Logistik',
|
||||||
|
[AssetType.PRODUCTION]: 'Produktion',
|
||||||
|
[AssetType.MIXED]: 'Gewerbefläche',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveImageLabel(p: ImageResolverParams): { type: string; location: string } {
|
||||||
|
const type = ASSET_LABELS[p.assetType ?? ''] ?? 'Gewerbefläche'
|
||||||
|
const district = p.district ?? p.city ?? ''
|
||||||
|
const location = p.city && district !== p.city ? `${p.city} · ${district}` : (p.city ?? district)
|
||||||
|
return { type, location }
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Inquiry } from '../domain/inquiry'
|
||||||
|
|
||||||
|
export const mockDemandInquiries: Inquiry[] = [
|
||||||
|
// 1 — NEW (unanswered, unread from manager)
|
||||||
|
{
|
||||||
|
id: 'dinq-001',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-technopark',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Martin Wegmann',
|
||||||
|
propertyManagerCompany: 'Technopark Zürich AG',
|
||||||
|
propertyAddress: 'Bürofläche Technoparkstrasse 1',
|
||||||
|
subject: 'Anfrage: Bürofläche Technoparkstrasse 1',
|
||||||
|
message:
|
||||||
|
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
matchScore: 94,
|
||||||
|
createdAt: '2026-05-22T09:15:00Z',
|
||||||
|
updatedAt: '2026-05-22T09:15:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-001-1',
|
||||||
|
inquiryId: 'dinq-001',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-22T09:15:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2 — NEW (unanswered, unread badge)
|
||||||
|
{
|
||||||
|
id: 'dinq-002',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-hardturmpark',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Claudia Stöckli',
|
||||||
|
propertyManagerCompany: 'PSP Swiss Property AG',
|
||||||
|
propertyAddress: 'Bürofläche Hardturmstrasse 161',
|
||||||
|
subject: 'Anfrage: Bürofläche Hardturmstrasse 161',
|
||||||
|
message:
|
||||||
|
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 1,
|
||||||
|
isRead: false,
|
||||||
|
matchScore: 88,
|
||||||
|
createdAt: '2026-05-23T14:30:00Z',
|
||||||
|
updatedAt: '2026-05-23T14:30:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-002-1',
|
||||||
|
inquiryId: 'dinq-002',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-23T14:30:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3 — IN_PROGRESS (ongoing conversation)
|
||||||
|
{
|
||||||
|
id: 'dinq-003',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-sihlcity',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Thomas Brunner',
|
||||||
|
propertyManagerCompany: 'Wincasa AG',
|
||||||
|
propertyAddress: 'Bürofläche Kalanderplatz 1, Sihlcity',
|
||||||
|
subject: 'Anfrage: Bürofläche Kalanderplatz 1, Sihlcity',
|
||||||
|
message:
|
||||||
|
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'in_progress',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-21T17:00:00Z',
|
||||||
|
matchScore: 91,
|
||||||
|
createdAt: '2026-05-19T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-21T16:45:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-1',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-19T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-2',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Thomas Brunner',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nVielen Dank für Ihr Interesse. Anbei das Exposé mit Grundriss und Konditionsübersicht. Die Fläche steht ab 1. August zur Verfügung. Haben Sie Interesse an einer Besichtigung diese Woche?\n\nFreundliche Grüsse\nThomas Brunner\nWincasa AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-003-1', fileName: 'Expose_Sihlcity_Kalanderplatz.pdf', fileType: 'application/pdf', fileSize: 1540000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-20T09:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-3',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Herzlichen Dank für die schnelle Antwort. Ja, eine Besichtigung würde uns gut passen. Wie sieht es am Donnerstag um 10:00 Uhr aus?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-21T16:45:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4 — IN_PROGRESS (ongoing, verwalter replied)
|
||||||
|
{
|
||||||
|
id: 'dinq-004',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-bahnhofzug',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Sandra Huber',
|
||||||
|
propertyManagerCompany: 'Swiss Prime Site AG',
|
||||||
|
propertyAddress: 'Bürofläche Bahnhofstrasse 28, Zug',
|
||||||
|
subject: 'Anfrage: Bürofläche Bahnhofstrasse 28, Zug',
|
||||||
|
message:
|
||||||
|
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'in_progress',
|
||||||
|
unreadCount: 2,
|
||||||
|
isRead: false,
|
||||||
|
matchScore: 86,
|
||||||
|
createdAt: '2026-05-16T08:45:00Z',
|
||||||
|
updatedAt: '2026-05-20T11:00:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-1',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-16T08:45:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-2',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Sandra Huber',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nVielen Dank für Ihre Nachricht. Die Fläche von ca. 520 m² ist ab Oktober verfügbar. Der Nettomietzins beträgt CHF 290/m² p.a. Nebenkosten werden nach Aufwand abgerechnet. Darf ich Ihnen ein Besichtigungsfenster vorschlagen?\n\nFreundliche Grüsse\nSandra Huber\nSwiss Prime Site AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-004-1', fileName: 'Konditionsblatt_Bahnhofstr28.pdf', fileType: 'application/pdf', fileSize: 820000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-18T14:20:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-3',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Sandra Huber',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nNur eine kurze Nachfrage — haben Sie die Unterlagen erhalten? Gerne würden wir einen Termin festlegen.\n\nFreundliche Grüsse\nSandra Huber',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5 — ANSWERED (completed exchange)
|
||||||
|
{
|
||||||
|
id: 'dinq-005',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-dreispitz',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Reto Mäder',
|
||||||
|
propertyManagerCompany: 'Allreal AG',
|
||||||
|
propertyAddress: 'Logistikfläche Dreispitz Basel',
|
||||||
|
subject: 'Anfrage: Logistikfläche Dreispitz Basel',
|
||||||
|
message:
|
||||||
|
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
|
||||||
|
status: 'answered',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-14T10:00:00Z',
|
||||||
|
matchScore: 79,
|
||||||
|
createdAt: '2026-05-10T11:00:00Z',
|
||||||
|
updatedAt: '2026-05-14T09:45:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-1',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-10T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-2',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Reto Mäder',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nJa, die Fläche ist noch verfügbar. Wir haben ca. 1\'800 m² mit 5.5 m Hallenhöhe und zwei Rampen. Besichtigung möglich ab nächster Woche. Anbei das Datenblatt.\n\nFreundliche Grüsse\nReto Mäder\nAllreal AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-005-1', fileName: 'Datenblatt_Dreispitz_Logistik.pdf', fileType: 'application/pdf', fileSize: 1100000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-12T08:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-3',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Herzlichen Dank. Die Unterlagen sehen vielversprechend aus. Wir melden uns bis Ende Woche mit einem Besichtigungstermin.\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-14T09:45:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -27,7 +27,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
location: 'Zürich-Oerlikon',
|
location: 'Zürich-Oerlikon',
|
||||||
matchScore: 87,
|
matchScore: 87,
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '1200 m²',
|
areaLabel: '1200 m²',
|
||||||
rentLabel: 'CHF 38/m²',
|
rentLabel: 'CHF 38/m²',
|
||||||
availabilityLabel: '2026-10-01',
|
availabilityLabel: '2026-10-01',
|
||||||
@@ -74,7 +74,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
location: 'Zürich-West / Technopark',
|
location: 'Zürich-West / Technopark',
|
||||||
matchScore: 76,
|
matchScore: 76,
|
||||||
resultType: 'FUTURE_AVAILABILITY',
|
resultType: 'FUTURE_AVAILABILITY',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '~600 m²',
|
areaLabel: '~600 m²',
|
||||||
availabilityLabel: '~10 Monate',
|
availabilityLabel: '~10 Monate',
|
||||||
addedAt: '2026-05-06T10:00:00Z',
|
addedAt: '2026-05-06T10:00:00Z',
|
||||||
@@ -121,7 +121,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
title: 'Gewerbe Güterstrasse Basel',
|
title: 'Gewerbe Güterstrasse Basel',
|
||||||
location: 'Basel',
|
location: 'Basel',
|
||||||
matchScore: 68,
|
matchScore: 68,
|
||||||
resultType: 'EXTERNAL_MARKET',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
stage: 'CLOSED_LOST',
|
stage: 'CLOSED_LOST',
|
||||||
areaLabel: '800 m²',
|
areaLabel: '800 m²',
|
||||||
rentLabel: 'CHF 28/m²',
|
rentLabel: 'CHF 28/m²',
|
||||||
|
|||||||
+44
-23
@@ -1,5 +1,6 @@
|
|||||||
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
|
||||||
|
|
||||||
export const mockProperties: Property[] = [
|
export const mockProperties: Property[] = [
|
||||||
|
|
||||||
@@ -1071,14 +1072,14 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// EXTERNAL_MARKET (10)
|
// VERIFIED_PORTFOLIO — formerly external market (migrated)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'prop-003',
|
id: 'prop-003',
|
||||||
title: 'Retail-Fläche Bahnhofstrasse 88',
|
title: 'Retail-Fläche Bahnhofstrasse 88',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
|
||||||
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 320,
|
areaSqm: 320,
|
||||||
@@ -1122,7 +1123,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-004',
|
id: 'prop-004',
|
||||||
title: 'Gemischte Gewerbeeinheit Europaallee',
|
title: 'Gemischte Gewerbeeinheit Europaallee',
|
||||||
assetType: AssetType.MIXED,
|
assetType: AssetType.MIXED,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
|
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
|
||||||
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 1150,
|
areaSqm: 1150,
|
||||||
@@ -1246,7 +1247,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-017',
|
id: 'prop-017',
|
||||||
title: 'Ladenfläche Löwenstrasse 28',
|
title: 'Ladenfläche Löwenstrasse 28',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
|
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
|
||||||
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 350,
|
areaSqm: 350,
|
||||||
@@ -1290,7 +1291,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-018',
|
id: 'prop-018',
|
||||||
title: 'Bürofläche Breitenrain 14',
|
title: 'Bürofläche Breitenrain 14',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
|
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
|
||||||
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
|
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 780,
|
areaSqm: 780,
|
||||||
@@ -1458,7 +1459,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-022',
|
id: 'prop-022',
|
||||||
title: 'Bürofläche St.Gallen Centrum 7',
|
title: 'Bürofläche St.Gallen Centrum 7',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
|
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
|
||||||
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
|
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
|
||||||
areaSqm: 700,
|
areaSqm: 700,
|
||||||
@@ -1666,7 +1667,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-031',
|
id: 'prop-031',
|
||||||
title: 'Bürofläche Hardturm West 16',
|
title: 'Bürofläche Hardturm West 16',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
|
||||||
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 780,
|
areaSqm: 780,
|
||||||
@@ -1780,7 +1781,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-034',
|
id: 'prop-034',
|
||||||
title: 'Ladenfläche Lorrainestrasse 8',
|
title: 'Ladenfläche Lorrainestrasse 8',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
|
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
|
||||||
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
|
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 340,
|
areaSqm: 340,
|
||||||
@@ -1844,7 +1845,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-036',
|
id: 'prop-036',
|
||||||
title: 'Lagerhalle Klybeckstrasse 280',
|
title: 'Lagerhalle Klybeckstrasse 280',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
|
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
|
||||||
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
|
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||||
areaSqm: 2600,
|
areaSqm: 2600,
|
||||||
@@ -2206,7 +2207,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-055',
|
id: 'prop-055',
|
||||||
title: 'Ladenfläche Weststrasse 44',
|
title: 'Ladenfläche Weststrasse 44',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
|
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
|
||||||
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
|
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 150,
|
areaSqm: 150,
|
||||||
@@ -2254,7 +2255,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-056',
|
id: 'prop-056',
|
||||||
title: 'Gewerbefläche Hohlstrasse 88',
|
title: 'Gewerbefläche Hohlstrasse 88',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
|
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
|
||||||
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 155,
|
areaSqm: 155,
|
||||||
@@ -2667,7 +2668,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-064',
|
id: 'prop-064',
|
||||||
title: 'Bürofläche Förrlibuckstrasse 22',
|
title: 'Bürofläche Förrlibuckstrasse 22',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
||||||
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 185,
|
areaSqm: 185,
|
||||||
@@ -2714,7 +2715,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-065',
|
id: 'prop-065',
|
||||||
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
|
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
|
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
|
||||||
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 175,
|
areaSqm: 175,
|
||||||
@@ -2813,7 +2814,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-067',
|
id: 'prop-067',
|
||||||
title: 'Bürofläche Altstetterstrasse 222',
|
title: 'Bürofläche Altstetterstrasse 222',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
||||||
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 190,
|
areaSqm: 190,
|
||||||
@@ -3175,7 +3176,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-074',
|
id: 'prop-074',
|
||||||
title: 'Bürofläche Uraniastrasse 14',
|
title: 'Bürofläche Uraniastrasse 14',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
|
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
|
||||||
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 510,
|
areaSqm: 510,
|
||||||
@@ -3223,7 +3224,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-075',
|
id: 'prop-075',
|
||||||
title: 'Bürofläche Seefeldstrasse 30',
|
title: 'Bürofläche Seefeldstrasse 30',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
|
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
|
||||||
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
|
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 540,
|
areaSqm: 540,
|
||||||
@@ -3483,7 +3484,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-080',
|
id: 'prop-080',
|
||||||
title: 'Ladenfläche Spitalgasse 34',
|
title: 'Ladenfläche Spitalgasse 34',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
||||||
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 280,
|
areaSqm: 280,
|
||||||
@@ -3531,7 +3532,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-081',
|
id: 'prop-081',
|
||||||
title: 'Ladenfläche Münstergasse 12',
|
title: 'Ladenfläche Münstergasse 12',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
||||||
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 350,
|
areaSqm: 350,
|
||||||
@@ -3838,7 +3839,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-087',
|
id: 'prop-087',
|
||||||
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
|
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
|
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
|
||||||
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
||||||
areaSqm: 3000,
|
areaSqm: 3000,
|
||||||
@@ -3885,7 +3886,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-088',
|
id: 'prop-088',
|
||||||
title: 'Lagerhalle Westquaistrasse 12',
|
title: 'Lagerhalle Westquaistrasse 12',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
|
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
|
||||||
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
|
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||||
areaSqm: 2600,
|
areaSqm: 2600,
|
||||||
@@ -4091,7 +4092,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-092',
|
id: 'prop-092',
|
||||||
title: 'Bürofläche Hohlstrasse 534, Altstetten',
|
title: 'Bürofläche Hohlstrasse 534, Altstetten',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
||||||
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 900,
|
areaSqm: 900,
|
||||||
@@ -4138,7 +4139,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-093',
|
id: 'prop-093',
|
||||||
title: 'Bürofläche Technopark Zürich-West',
|
title: 'Bürofläche Technopark Zürich-West',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
||||||
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 960,
|
areaSqm: 960,
|
||||||
@@ -4343,7 +4344,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-097',
|
id: 'prop-097',
|
||||||
title: 'Bürofläche Baarerstrasse 18, Zug',
|
title: 'Bürofläche Baarerstrasse 18, Zug',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
|
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
|
||||||
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
|
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
|
||||||
areaSqm: 320,
|
areaSqm: 320,
|
||||||
@@ -4487,3 +4488,23 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Assign images sequentially per pool so no two properties ever share the same image
|
||||||
|
// (until pool wraps, which happens after ~12 properties per pool type)
|
||||||
|
const _poolCounters: Record<string, number> = {}
|
||||||
|
mockProperties.forEach(p => {
|
||||||
|
const district = (p.location.district ?? '').toLowerCase()
|
||||||
|
const prestige = p.softFactors?.prestige ?? 60
|
||||||
|
const key = getPoolKey(p.assetType, district, prestige)
|
||||||
|
const poolIndex = _poolCounters[key] ?? 0
|
||||||
|
_poolCounters[key] = poolIndex + 1
|
||||||
|
p.images = [resolvePropertyImage({
|
||||||
|
assetType: p.assetType,
|
||||||
|
city: p.location.city,
|
||||||
|
district: p.location.district,
|
||||||
|
prestige: p.softFactors?.prestige,
|
||||||
|
floorLevel: p.floorLevel,
|
||||||
|
propertyId: p.id,
|
||||||
|
poolIndex,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 { mockInquiries } from '../../mock-data/inquiries'
|
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
@@ -13,6 +14,7 @@ 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'
|
||||||
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,9 +36,13 @@ export default function Anfragen() {
|
|||||||
|
|
||||||
const preselectedId = searchParams.get('inquiry')
|
const preselectedId = searchParams.get('inquiry')
|
||||||
|
|
||||||
const [inquiries, setInquiries] = useState(mockInquiries)
|
const { currentUser } = useSessionStore()
|
||||||
|
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||||
|
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
||||||
|
const allInquiries = [...storeInquiries, ...inquiries]
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
preselectedId ?? mockInquiries[0]?.id ?? null
|
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
||||||
)
|
)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('all')
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
@@ -45,18 +51,21 @@ export default function Anfragen() {
|
|||||||
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)
|
||||||
|
|
||||||
const filtered = inquiries.filter(inq => {
|
const filtered = allInquiries.filter(inq => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const matchesSearch = !q ||
|
const matchesSearch = !q ||
|
||||||
inq.tenantName.toLowerCase().includes(q) ||
|
inq.tenantName.toLowerCase().includes(q) ||
|
||||||
inq.subject.toLowerCase().includes(q) ||
|
inq.subject.toLowerCase().includes(q) ||
|
||||||
(inq.tenantCompany?.toLowerCase().includes(q) ?? false)
|
(inq.tenantCompany?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
||||||
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
||||||
return matchesSearch && matchesStatus
|
return matchesSearch && matchesStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = inquiries.find(i => i.id === selectedId) ?? null
|
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||||
const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||||
|
|
||||||
// Pipeline link for currently selected inquiry
|
// Pipeline link for currently selected inquiry
|
||||||
const linkedPipelineItem = selected?.propertyId
|
const linkedPipelineItem = selected?.propertyId
|
||||||
@@ -176,6 +185,7 @@ export default function Anfragen() {
|
|||||||
inq={inq}
|
inq={inq}
|
||||||
isSelected={inq.id === selectedId}
|
isSelected={inq.id === selectedId}
|
||||||
hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
|
hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
|
||||||
|
perspective="demand"
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -205,12 +215,16 @@ export default function Anfragen() {
|
|||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}>
|
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}>
|
||||||
{selected.tenantName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
{(selected.propertyAddress ?? selected.subject).split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.tenantName}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.propertyAddress ?? selected.subject}</Typography>
|
||||||
{selected.tenantCompany && <Typography variant="caption" color="text.secondary">{selected.tenantCompany}</Typography>}
|
{(selected.propertyManagerCompany ?? selected.propertyManagerName) && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{selected.propertyManagerCompany ?? selected.propertyManagerName ?? ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
||||||
{selected.subject}
|
{selected.subject}
|
||||||
@@ -281,7 +295,7 @@ export default function Anfragen() {
|
|||||||
{/* 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 => (
|
||||||
<AnfragenMessageBubble key={msg.id} msg={msg} />
|
<AnfragenMessageBubble key={msg.id} msg={msg} currentUserName={currentUser?.name ?? undefined} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||||
import { PropertyMap } from '../../components/shared'
|
import { PropertyMap } from '../../components/shared'
|
||||||
@@ -46,6 +48,7 @@ export default function MatchDetail() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const { openSavedDialog } = usePipelineStore()
|
const { openSavedDialog } = usePipelineStore()
|
||||||
|
const { openInquiryDialog } = useInquiryStore()
|
||||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||||
|
|
||||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||||
@@ -88,7 +91,7 @@ export default function MatchDetail() {
|
|||||||
const handleCompare = () => {
|
const handleCompare = () => {
|
||||||
if (match && !isFuture && property) {
|
if (match && !isFuture && property) {
|
||||||
addToCompare({
|
addToCompare({
|
||||||
resultType: property.resultType === 'EXTERNAL_MARKET' ? 'EXTERNAL_MARKET' : 'VERIFIED_PORTFOLIO',
|
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||||
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
||||||
})
|
})
|
||||||
} else if (match && isFuture && signal) {
|
} else if (match && isFuture && signal) {
|
||||||
@@ -126,6 +129,7 @@ export default function MatchDetail() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
|
|
||||||
{/* Sticky back nav */}
|
{/* Sticky back nav */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -164,7 +168,7 @@ export default function MatchDetail() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Main content ── */}
|
{/* ── Main content ── */}
|
||||||
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
|
||||||
{/* ── Section A: Warum dieser Match? ── */}
|
{/* ── Section A: Warum dieser Match? ── */}
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -196,9 +200,14 @@ export default function MatchDetail() {
|
|||||||
<NextActionsPanel
|
<NextActionsPanel
|
||||||
match={match}
|
match={match}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
onShortlist={handleShortlist}
|
onPipeline={handleShortlist}
|
||||||
onReview={() => {}}
|
onInquire={() => openInquiryDialog({
|
||||||
onReject={() => {}}
|
propertyTitle: title,
|
||||||
|
location,
|
||||||
|
areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : undefined,
|
||||||
|
rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Full analysis toggle ── */}
|
{/* ── Full analysis toggle ── */}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||||
import { TrendingUp } from 'lucide-react'
|
import { TrendingUp } from 'lucide-react'
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||||
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
||||||
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
||||||
@@ -22,6 +24,7 @@ export default function Pipeline() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: items = [] } = usePipelineItems()
|
const { data: items = [] } = usePipelineItems()
|
||||||
const { mutate: moveStage } = useMoveStage()
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const [overId, setOverId] = useState<string | null>(null)
|
const [overId, setOverId] = useState<string | null>(null)
|
||||||
@@ -66,6 +69,7 @@ export default function Pipeline() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -123,6 +127,12 @@ export default function Pipeline() {
|
|||||||
selectedId={syncedSelected?.id ?? null}
|
selectedId={syncedSelected?.id ?? null}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
|
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
|
||||||
|
onCardChatClick={(item) => openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.location ?? '',
|
||||||
|
matchScore: item.matchScore ?? 0,
|
||||||
|
propertyId: item.propertyId,
|
||||||
|
})}
|
||||||
isOver={isOver}
|
isOver={isOver}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UnifiedResultFeed,
|
UnifiedResultFeed,
|
||||||
} from '../../components/results'
|
} from '../../components/results'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import { ErrorState } from '../../components/ui'
|
import { ErrorState } from '../../components/ui'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -65,7 +66,8 @@ export default function Results() {
|
|||||||
}
|
}
|
||||||
}, [activeNeedIdFromNav, queryClient])
|
}, [activeNeedIdFromNav, queryClient])
|
||||||
|
|
||||||
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
|
const needsLoading = allNeeds.length === 0 && !activeNeedIdFromNav
|
||||||
|
const { data: results = [], isLoading, error } = useUnifiedResults(needsLoading ? undefined : effectiveNeedId)
|
||||||
|
|
||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ export default function Results() {
|
|||||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
||||||
let platform = 0, maison = 0, future = 0, missing = 0
|
let platform = 0, maison = 0, future = 0, missing = 0
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET') platform++
|
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
||||||
else if (r.resultType === 'MAISON_WORK') maison++
|
else if (r.resultType === 'MAISON_WORK') maison++
|
||||||
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
||||||
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||||
@@ -91,7 +93,6 @@ export default function Results() {
|
|||||||
return filterSource === 'ALL' || filterSource === 'PLATFORM'
|
return filterSource === 'ALL' || filterSource === 'PLATFORM'
|
||||||
}
|
}
|
||||||
if (filterSource === 'ALL') return true
|
if (filterSource === 'ALL') return true
|
||||||
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
|
|
||||||
return r.resultType === filterSource
|
return r.resultType === filterSource
|
||||||
})
|
})
|
||||||
return sortResults(filtered, sortBy)
|
return sortResults(filtered, sortBy)
|
||||||
@@ -102,6 +103,7 @@ export default function Results() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
<ResultFeedHeader
|
<ResultFeedHeader
|
||||||
total={sorted.length}
|
total={sorted.length}
|
||||||
platformCount={platformCount}
|
platformCount={platformCount}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { PipelineStage } from '../../domain/pipeline'
|
import type { PipelineStage } from '../../domain/pipeline'
|
||||||
|
|
||||||
export const STAGE_ORDER: PipelineStage[] = [
|
export const STAGE_ORDER: PipelineStage[] = [
|
||||||
'SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
|
'SAVED', 'CONTACTED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const STAGE_LABELS: Record<string, string> = {
|
export const STAGE_LABELS: Record<string, string> = {
|
||||||
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
|
SAVED: 'Interessiert', CONTACTED: 'Angefragt',
|
||||||
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
|
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type { Property } from '../../domain/property'
|
|||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro',
|
OFFICE: 'Büro',
|
||||||
RETAIL: 'Einzelhandel',
|
RETAIL: 'Einzelhandel',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SoftFactorsSection,
|
SoftFactorsSection,
|
||||||
TechnicalDetailsSection,
|
TechnicalDetailsSection,
|
||||||
ImageUrlSection,
|
ImageUrlSection,
|
||||||
|
FloorPlanUrlSection,
|
||||||
ContactSection,
|
ContactSection,
|
||||||
CreatedScreen,
|
CreatedScreen,
|
||||||
} from '../../components/new-listing'
|
} from '../../components/new-listing'
|
||||||
@@ -92,6 +93,11 @@ export default function NewListing() {
|
|||||||
onRemove={form.removeImage}
|
onRemove={form.removeImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FloorPlanUrlSection
|
||||||
|
floorPlanUrl={form.floorPlanUrl}
|
||||||
|
onFloorPlanUrlChange={form.setFloorPlanUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
<ContactSection
|
<ContactSection
|
||||||
name={form.contactName} onNameChange={form.setContactName}
|
name={form.contactName} onNameChange={form.setContactName}
|
||||||
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro',
|
OFFICE: 'Büro',
|
||||||
RETAIL: 'Einzelhandel',
|
RETAIL: 'Einzelhandel',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export function buildCreatePropertyInput(fields: {
|
|||||||
parking: string
|
parking: string
|
||||||
ceilingHeight: string
|
ceilingHeight: string
|
||||||
images: string[]
|
images: string[]
|
||||||
|
floorPlanUrl: string
|
||||||
}): CreatePropertyInput {
|
}): CreatePropertyInput {
|
||||||
const {
|
const {
|
||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm, rentPerSqm, availableFrom, description,
|
areaSqm, rentPerSqm, availableFrom, description,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight, images,
|
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
} = fields
|
} = fields
|
||||||
|
|
||||||
const sf = {
|
const sf = {
|
||||||
@@ -47,7 +48,7 @@ export function buildCreatePropertyInput(fields: {
|
|||||||
return {
|
return {
|
||||||
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
|
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
|
||||||
assetType: assetType as typeof AssetType[keyof typeof AssetType],
|
assetType: assetType as typeof AssetType[keyof typeof AssetType],
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
sourceType: 'DIRECT',
|
sourceType: 'DIRECT',
|
||||||
location: { city, country: 'CH' },
|
location: { city, country: 'CH' },
|
||||||
address: {
|
address: {
|
||||||
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
|
|||||||
softFactors: sf,
|
softFactors: sf,
|
||||||
hardFacts: hf,
|
hardFacts: hf,
|
||||||
images: images.length > 0 ? images : undefined,
|
images: images.length > 0 ? images : undefined,
|
||||||
|
floorPlanUrl: floorPlanUrl.trim() || undefined,
|
||||||
dataQuality: {
|
dataQuality: {
|
||||||
score: 1.0,
|
score: 1.0,
|
||||||
missingCriticalFields: [],
|
missingCriticalFields: [],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
totalProperties: number
|
totalProperties: number
|
||||||
verifiedProperties: number
|
verifiedProperties: number
|
||||||
externalMarketProperties: number
|
verifiedPortfolioProperties: number
|
||||||
futureSignalProperties: number
|
futureSignalProperties: number
|
||||||
totalMatches: number
|
totalMatches: number
|
||||||
pendingReviews: number
|
pendingReviews: number
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const MockupDashboardProvider: IDashboardProvider = {
|
|||||||
const stats: DashboardStats = {
|
const stats: DashboardStats = {
|
||||||
totalProperties: props.length,
|
totalProperties: props.length,
|
||||||
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
||||||
externalMarketProperties: props.filter(p => p.resultType === 'EXTERNAL_MARKET').length,
|
verifiedPortfolioProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
||||||
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
|
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
|
||||||
totalMatches: matches.length,
|
totalMatches: matches.length,
|
||||||
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
|
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||||
import type { Match } from '../domain/match'
|
import type { Match } from '../domain/match'
|
||||||
|
|
||||||
// Matches are computed dynamically via calculateScore so weights always reflect need.weightingProfile
|
// Populated at startup by MockupNeedProvider via syncMatchesForNeed (deterministic IDs, no duplicates)
|
||||||
export const matchStore: Match[] = []
|
export const matchStore: Match[] = []
|
||||||
const store = matchStore
|
const store = matchStore
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export const MockupMatchProvider: IMatchProvider = {
|
|||||||
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
||||||
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
||||||
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
|
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
|
||||||
|
// Deduplicate by id — guards against double-push during dev hot-reload
|
||||||
|
const seen = new Set<string>()
|
||||||
|
results = results.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true })
|
||||||
return results.sort((a, b) => b.matchScore - a.matchScore)
|
return results.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
},
|
},
|
||||||
async getById(id) {
|
async getById(id) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const MockupNeedProvider: INeedProvider = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
// Recompute matches for all pre-existing needs — replaces static mock matches so scores reflect need.weightingProfile
|
||||||
for (const need of store) {
|
for (const need of store) {
|
||||||
generateMatchesForNeed(need)
|
syncMatchesForNeed(need)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,22 @@ import { mockPipelineItems } from '../mock-data/pipelineItems'
|
|||||||
|
|
||||||
const STORAGE_KEY = 'property_match_pipeline_items'
|
const STORAGE_KEY = 'property_match_pipeline_items'
|
||||||
|
|
||||||
|
const STAGE_MIGRATION: Record<string, PipelineStage> = {
|
||||||
|
DISCOVERED: 'CONTACTED',
|
||||||
|
QUALIFIED: 'CONTACTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStage(stage: string): PipelineStage {
|
||||||
|
return (STAGE_MIGRATION[stage] ?? stage) as PipelineStage
|
||||||
|
}
|
||||||
|
|
||||||
function load(): PipelineItem[] {
|
function load(): PipelineItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) return JSON.parse(raw) as PipelineItem[]
|
if (raw) {
|
||||||
|
const items = JSON.parse(raw) as PipelineItem[]
|
||||||
|
return items.map(i => ({ ...i, stage: migrateStage(i.stage) }))
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [...mockPipelineItems]
|
return [...mockPipelineItems]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
|
|||||||
|
|
||||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||||
const ZURICH_ONLY_SIGNALS = [
|
const ZURICH_ONLY_SIGNALS = [
|
||||||
'industrie groove', 'industrie-groove',
|
'pfingstweidstrasse', 'freilager', 'europaallee',
|
||||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
|
||||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||||
'hürlimann', 'viadukt', 'schiffbau',
|
'hürlimann', 'viadukt', 'schiffbau',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function buildMatch(
|
|||||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: `m__${prop.id}__${unitId ?? 'prop'}__${need.id}`,
|
||||||
propertyId: prop.id,
|
propertyId: prop.id,
|
||||||
unitId,
|
unitId,
|
||||||
needId: need.id,
|
needId: need.id,
|
||||||
@@ -92,9 +92,12 @@ export function generateMatchesForNeed(need: Need): void {
|
|||||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||||
|
|
||||||
if (hasExplicitUnits) {
|
if (hasExplicitUnits) {
|
||||||
const output = scoreProperty(need, prop)
|
const allPreMarket = prop.units!.every(u => u.schattenmarktRelease?.enabled)
|
||||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
if (!allPreMarket) {
|
||||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
const output = scoreProperty(need, prop)
|
||||||
|
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||||
|
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const unit of prop.units!) {
|
for (const unit of prop.units!) {
|
||||||
if (!unit.schattenmarktRelease?.enabled) continue
|
if (!unit.schattenmarktRelease?.enabled) continue
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
|
||||||
|
|
||||||
|
export interface PendingInquiry {
|
||||||
|
propertyTitle: string
|
||||||
|
location: string
|
||||||
|
areaLabel?: string
|
||||||
|
rentLabel?: string
|
||||||
|
matchScore: number
|
||||||
|
matchId?: string
|
||||||
|
propertyId?: string
|
||||||
|
pipelineItemId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InquiryStore {
|
||||||
|
// Dialog state
|
||||||
|
dialogOpen: boolean
|
||||||
|
pendingInquiry: PendingInquiry | null
|
||||||
|
openInquiryDialog: (item: PendingInquiry) => 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) => ({
|
||||||
|
dialogOpen: false,
|
||||||
|
pendingInquiry: null,
|
||||||
|
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
|
||||||
|
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