Compare commits
5 Commits
11de05025c
...
e95490eb72
| Author | SHA1 | Date | |
|---|---|---|---|
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd |
@@ -11,7 +11,9 @@
|
||||
"Bash(start http://localhost:5173)",
|
||||
"Bash(npm install *)",
|
||||
"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": {
|
||||
"allow": [
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git commit *)"
|
||||
"Bash(git commit *)",
|
||||
"Bash(node -e ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { DS_COLORS } from '../../lib/ds'
|
||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
||||
|
||||
const ICONS: Record<ResultType, React.ElementType> = {
|
||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||
EXTERNAL_MARKET: Globe,
|
||||
MAISON_WORK: Building2,
|
||||
FUTURE_AVAILABILITY: Sparkles,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
|
||||
|
||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||
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 },
|
||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||
}
|
||||
|
||||
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
|
||||
inq: Inquiry
|
||||
isSelected: boolean
|
||||
hasPipeline: boolean
|
||||
perspective?: 'demand' | 'supply'
|
||||
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 displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||
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 (
|
||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||
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 }}>
|
||||
{!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>
|
||||
{inq.tenantName}
|
||||
{primaryTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
{inq.tenantCompany && (
|
||||
{secondaryLine && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||
{inq.tenantCompany}
|
||||
{secondaryLine}
|
||||
</Typography>
|
||||
)}
|
||||
<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>
|
||||
{lastMsg && (
|
||||
<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]}
|
||||
</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 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 isAI = msg.senderType === 'ai'
|
||||
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 && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{msg.senderName} · {date} {time}
|
||||
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
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']
|
||||
|
||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
||||
{ label: 'Retail', value: AssetType.RETAIL },
|
||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||
]
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
|
||||
|
||||
const TYPE_DOT: Record<string, string> = {
|
||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||
EXTERNAL_MARKET: '#d97706',
|
||||
MAISON_WORK: '#0369a1',
|
||||
FUTURE_AVAILABILITY: '#7c3aed',
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { HeatBadge } from '../shared/HeatBadge'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
@@ -19,12 +20,15 @@ interface Props {
|
||||
lat?: number
|
||||
lng?: number
|
||||
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 theme = SCORE_THEME[tier]
|
||||
const { currentUser } = useSessionStore()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
|
||||
{/* ── Hero zone ── */}
|
||||
<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={{
|
||||
position: 'absolute', top: 10, left: 10,
|
||||
background: theme.gradient, borderRadius: '10px',
|
||||
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
)}
|
||||
|
||||
{/* 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 => (
|
||||
<Button
|
||||
key={a.id}
|
||||
|
||||
@@ -1,119 +1,216 @@
|
||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { MatchCardHeader } from './MatchCardHeader'
|
||||
import { MatchReasonList } from './MatchReasonList'
|
||||
import { TradeoffList } from './TradeoffList'
|
||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import { memo } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { HeatBadge } from '../shared/HeatBadge'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
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 />
|
||||
|
||||
const borderColor = vm.isCompareSelected
|
||||
? '#7c3aed'
|
||||
: vm.isSelected
|
||||
? '#1e3a5f'
|
||||
: 'transparent'
|
||||
const { currentUser } = useSessionStore()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||
|
||||
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 (
|
||||
<Card
|
||||
sx={{
|
||||
p: 2.5,
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `2px solid ${borderColor}`,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||
background: theme.cardBg,
|
||||
opacity: vm.isStaleData ? 0.75 : 1,
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
||||
transition: 'box-shadow 0.15s',
|
||||
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||
}}>
|
||||
|
||||
{/* ── Image strip ── */}
|
||||
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||
{imageUrl ? (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Score badge */}
|
||||
<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>
|
||||
|
||||
{/* Asset type label */}
|
||||
{overlayTypeLabel && (
|
||||
<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.5, py: 0.5 }}>
|
||||
{vm.disclaimer}
|
||||
</Alert>
|
||||
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||
)}
|
||||
|
||||
{vm.isReviewRequired && (
|
||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
Manuelle Überprüfung erforderlich
|
||||
</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>
|
||||
|
||||
{/* Header: score → type → confidence → availability → risk */}
|
||||
<MatchCardHeader vm={vm} compact />
|
||||
|
||||
{/* Title + location (max 2 lines) */}
|
||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
||||
{/* Title + location */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
{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}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
<MapPin size={13} color="#64748b" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||
<MapPin size={11} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Explainability summary */}
|
||||
{vm.explainabilitySummary && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.25 }} />
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Score formula — always visible */}
|
||||
{/* Score breakdown */}
|
||||
{vm.scoreBreakdown && (
|
||||
<Box sx={{ mb: 1.25 }}>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Top reason only */}
|
||||
{/* Reasons — all 3, same style as grid card */}
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
||||
<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 only (compact) */}
|
||||
{/* Top tradeoff */}
|
||||
{vm.tradeoffs.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 />
|
||||
{/* 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>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||
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 }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
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'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
</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 */}
|
||||
{property.description && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.dataQuality.lastVerifiedAt && (
|
||||
<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 }}>
|
||||
<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
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import type { Match, NextBestAction } from '../../domain/match'
|
||||
|
||||
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 {
|
||||
match: Match
|
||||
onCompare?: () => void
|
||||
onShortlist?: () => void
|
||||
onReject?: () => void
|
||||
onReview?: () => void
|
||||
onPipeline?: () => void
|
||||
onInquire?: () => void
|
||||
}
|
||||
|
||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
||||
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||
)
|
||||
@@ -26,19 +20,27 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<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 sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{onInquire && (
|
||||
<Button
|
||||
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={PRIORITY_COLOR[action.priority]}
|
||||
sx={
|
||||
action.priority === 'HIGH'
|
||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
||||
: { justifyContent: 'flex-start' }
|
||||
}
|
||||
variant="outlined"
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
@@ -49,14 +51,10 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Standard actions */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{onShortlist && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
||||
Zur Shortlist hinzufügen
|
||||
{onPipeline && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||
Zur Pipeline hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
{onCompare && (
|
||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
||||
Zum Vergleich hinzufügen
|
||||
</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>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Train,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import {
|
||||
ASSET_LABELS,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
SOURCE_LABELS,
|
||||
UnitRow,
|
||||
} from './MatchDetailPropertyDetails'
|
||||
import { FloorPlanSection } from './FloorPlanSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
|
||||
interface PropertyDetailPublicSectionsProps {
|
||||
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
</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 ── */}
|
||||
{property.description && (
|
||||
<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' })}
|
||||
/>
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button
|
||||
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 { ContactSection } from './ContactSection'
|
||||
export { CreatedScreen } from './CreatedScreen'
|
||||
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 { 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 { HeatBadge } from '../shared'
|
||||
import type { PipelineItem } from '../../domain/pipeline'
|
||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||
import { detailPath } from './pipelineUtils'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
|
||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
||||
onChatClick?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
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 style = !isDragOverlay ? {
|
||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
||||
</Tooltip>
|
||||
)}
|
||||
{path && (
|
||||
<Tooltip title="Objekt öffnen">
|
||||
<Tooltip title="Match öffnen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
||||
{item.notes}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onChatClick,
|
||||
onCardChatClick,
|
||||
isOver,
|
||||
}: {
|
||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
||||
selectedId: string | null
|
||||
onSelect: (item: PipelineItem) => void
|
||||
onChatClick: (inquiryId: string) => void
|
||||
onCardChatClick?: (item: PipelineItem) => void
|
||||
isOver: boolean
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
||||
item={item}
|
||||
isSelected={item.id === selectedId}
|
||||
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 && (
|
||||
|
||||
@@ -4,9 +4,8 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const STAGES = [
|
||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
||||
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||
@@ -14,9 +13,8 @@ export const STAGES = [
|
||||
] as const
|
||||
|
||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||
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 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}`
|
||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
? result.property.location.city
|
||||
: 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 (
|
||||
<IntelligenceMatchCard
|
||||
vm={vm}
|
||||
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
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
|
||||
cityLabel?: string
|
||||
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 {
|
||||
if (lat != null && lng != null) {
|
||||
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'
|
||||
}
|
||||
|
||||
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 mapsUrl = buildMapsUrl(lat, lng, address)
|
||||
const showImage = !!imageUrl && !imgError
|
||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
||||
</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 */}
|
||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||
<IconButton
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
|
||||
matchId: item.resultId,
|
||||
needId: '',
|
||||
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,
|
||||
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> = {
|
||||
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||
EXTERNAL_MARKET: 'Direkt',
|
||||
MAISON_WORK: 'Maison Work',
|
||||
FUTURE_AVAILABILITY: 'Signal',
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
||||
GASTRO: 'Gastro',
|
||||
PRODUCTION: 'Produktion',
|
||||
MIXED: 'Gemischt',
|
||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
return labels[type] ?? type
|
||||
|
||||
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
|
||||
// ── Result Types ──────────────────────────────────────────────────────────────
|
||||
export const ResultType = {
|
||||
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
||||
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
|
||||
MAISON_WORK: 'MAISON_WORK',
|
||||
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
||||
} as const
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface Inquiry {
|
||||
tenantName: string
|
||||
tenantCompany?: string
|
||||
tenantEmail?: string
|
||||
propertyManagerName?: string
|
||||
propertyManagerCompany?: string
|
||||
propertyAddress?: string
|
||||
subject: string
|
||||
message: string
|
||||
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 {
|
||||
id: string
|
||||
title: string
|
||||
location: string
|
||||
matchScore: number
|
||||
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||
stage: PipelineStage
|
||||
// Source match reference — used for navigating back to the detail page
|
||||
matchId?: string
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Property {
|
||||
riskLevel?: RiskLevel
|
||||
description?: string
|
||||
images?: string[]
|
||||
floorPlanUrl?: string
|
||||
|
||||
propertyNumber?: string
|
||||
units?: PropertyUnit[]
|
||||
|
||||
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
|
||||
export const CONFIDENCE_MODIFIER = {
|
||||
VERIFIED_HIGH: +3, // 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)
|
||||
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
||||
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
||||
|
||||
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
|
||||
}
|
||||
|
||||
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
||||
resultType: 'VERIFIED_PORTFOLIO'
|
||||
property: Property
|
||||
match: Match
|
||||
unit?: PropertyUnit // specific unit this match refers to
|
||||
}
|
||||
|
||||
export interface ExternalMarketResult extends UnifiedResultBase {
|
||||
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
||||
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
|
||||
property: Property
|
||||
match: Match
|
||||
unit?: PropertyUnit // specific unit this match refers to
|
||||
@@ -38,5 +31,4 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
||||
|
||||
export type UnifiedMatchResult =
|
||||
| VerifiedPortfolioResult
|
||||
| ExternalMarketResult
|
||||
| FutureAvailabilityResult
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PropertyUnit {
|
||||
currentTenant?: string
|
||||
leaseTerm?: string
|
||||
leaseEndDate?: string
|
||||
floorPlanUrl?: string // optional floor plan image
|
||||
// Flexible letting (Teilfläche)
|
||||
isFlexible?: boolean
|
||||
minLettableSqm?: number
|
||||
|
||||
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
|
||||
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
|
||||
const prop = makeProperty()
|
||||
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
|
||||
if (verified.matchScore !== external.matchScore) {
|
||||
const lower = Math.min(verified.matchScore, external.matchScore)
|
||||
verified.matchScore = lower
|
||||
external.matchScore = lower
|
||||
}
|
||||
// Force same score for a clean tiebreak test
|
||||
verified.matchScore = 75
|
||||
future.matchScore = 75
|
||||
|
||||
const ranked = rankMatches([external, verified])
|
||||
const ranked = rankMatches([future, verified])
|
||||
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
||||
})
|
||||
|
||||
|
||||
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
|
||||
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', () => {
|
||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-2)
|
||||
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
|
||||
})
|
||||
|
||||
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
||||
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
|
||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-13)
|
||||
// MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
|
||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-12)
|
||||
})
|
||||
|
||||
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
||||
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
|
||||
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 prop = makeProperty({
|
||||
location: { city: 'Basel', country: 'CH' },
|
||||
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
|
||||
dataQuality: {
|
||||
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', () => {
|
||||
it('returns identical estimate for same location regardless of resultType', () => {
|
||||
// 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 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,
|
||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||
})
|
||||
const marketResult = makeProperty({
|
||||
const portfolioResult = makeProperty({
|
||||
location: sharedLocation,
|
||||
address: sharedAddress,
|
||||
resultType: ResultType.EXTERNAL_MARKET,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
})
|
||||
|
||||
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
||||
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
|
||||
expect(futureEst?.score).toBe(marketEst?.score)
|
||||
expect(futureEst?.label).toBe(marketEst?.label)
|
||||
const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
|
||||
expect(futureEst?.score).toBe(portfolioEst?.score)
|
||||
expect(futureEst?.label).toBe(portfolioEst?.label)
|
||||
})
|
||||
|
||||
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) => {
|
||||
// Primary: matchScore descending
|
||||
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
||||
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
|
||||
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
|
||||
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
||||
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
||||
// Secondary: VERIFIED / MAISON_WORK > FUTURE
|
||||
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
|
||||
const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||
const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||
if (aOrder !== bOrder) return aOrder - bOrder
|
||||
// Tertiary: higher confidence first
|
||||
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
||||
|
||||
@@ -499,8 +499,6 @@ export function calcConfidenceModifier(property: Property): number {
|
||||
let mod = 0
|
||||
if (property.resultType === ResultType.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) {
|
||||
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
||||
} else {
|
||||
@@ -586,17 +584,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
]
|
||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||
|
||||
// ── Modifiers: data quality and confidence adjust the final score ─────────
|
||||
// These are intentionally applied after the hard/soft weighted sum so that
|
||||
// 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
|
||||
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||||
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||
|
||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||
@@ -624,8 +613,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
finalScore,
|
||||
hardMatchScore,
|
||||
softFactorScore,
|
||||
dataQualityModifier,
|
||||
confidenceModifier,
|
||||
dataQualityModifier: 0,
|
||||
confidenceModifier: 0,
|
||||
positiveFactors,
|
||||
negativeFactors,
|
||||
allHardFactors: hardFactors,
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface NewListingFormState {
|
||||
// Images
|
||||
images: string[]
|
||||
imageInput: string
|
||||
floorPlanUrl: string
|
||||
// AI
|
||||
aiText: string
|
||||
aiApplied: boolean
|
||||
@@ -82,6 +83,7 @@ export interface NewListingFormHandlers {
|
||||
setContactEmail: (v: string) => void
|
||||
setContactPhone: (v: string) => void
|
||||
setImageInput: (v: string) => void
|
||||
setFloorPlanUrl: (v: string) => void
|
||||
setAiText: (v: string) => void
|
||||
addImage: () => void
|
||||
removeImage: (index: number) => void
|
||||
@@ -113,6 +115,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [imageInput, setImageInput] = useState('')
|
||||
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||
const [aiText, setAiText] = useState('')
|
||||
const [aiApplied, setAiApplied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -157,7 +160,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||
availableFrom, description, softLevels,
|
||||
floor, fitOut, parking, ceilingHeight, images,
|
||||
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => setCreated(true),
|
||||
@@ -173,7 +176,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||
setImages([]); setImageInput('')
|
||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
setCreated(false); setError(null)
|
||||
}
|
||||
@@ -183,7 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
street, houseNumber, postalCode, city,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||
contactName, contactEmail, contactPhone,
|
||||
images, imageInput, aiText, aiApplied,
|
||||
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||
error, created,
|
||||
aiParsing: parseListingMutation.isPending,
|
||||
submitting: createProperty.isPending,
|
||||
@@ -193,7 +196,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setSoftLevel,
|
||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||
setContactName, setContactEmail, setContactPhone,
|
||||
setImageInput, setAiText,
|
||||
setImageInput, setFloorPlanUrl, setAiText,
|
||||
addImage, removeImage,
|
||||
handleAiParse, handleSubmit, resetForm,
|
||||
}
|
||||
|
||||
@@ -6,17 +6,16 @@ import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||
import type {
|
||||
UnifiedMatchResult,
|
||||
VerifiedPortfolioResult,
|
||||
ExternalMarketResult,
|
||||
FutureAvailabilityResult,
|
||||
} from '../domain/unifiedResult'
|
||||
|
||||
export function useUnifiedResults(needId?: string) {
|
||||
const allMatchesQuery = useMatches()
|
||||
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
||||
const propertiesQuery = useProperties()
|
||||
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 =
|
||||
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
||||
@@ -64,24 +63,11 @@ export function useUnifiedResults(needId?: string) {
|
||||
? (property.units?.find(u => u.id === match.unitId) ?? 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 = {
|
||||
matchId: match.id,
|
||||
needId: match.needId,
|
||||
matchScore: match.matchScore,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
resultType: rt as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||
property,
|
||||
match,
|
||||
unit: matchUnit,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||
RETAIL: 'Retail',
|
||||
GASTRO: 'Gastronomie',
|
||||
LOGISTICS: 'Logistik',
|
||||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
PRODUCTION: 'Produktion',
|
||||
MIXED: 'Gemischt',
|
||||
}
|
||||
@@ -69,7 +70,6 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||
// Result type display labels
|
||||
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
||||
VERIFIED_PORTFOLIO: 'Plattform',
|
||||
EXTERNAL_MARKET: 'Plattform',
|
||||
MAISON_WORK: 'Maison Work',
|
||||
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
|
||||
export const DS_COLORS = {
|
||||
resultType: {
|
||||
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' },
|
||||
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 }> = {
|
||||
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)' },
|
||||
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) {
|
||||
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.FUTURE_AVAILABILITY) {
|
||||
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',
|
||||
matchScore: 87,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
stage: 'QUALIFIED',
|
||||
stage: 'CONTACTED',
|
||||
areaLabel: '1200 m²',
|
||||
rentLabel: 'CHF 38/m²',
|
||||
availabilityLabel: '2026-10-01',
|
||||
@@ -74,7 +74,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
||||
location: 'Zürich-West / Technopark',
|
||||
matchScore: 76,
|
||||
resultType: 'FUTURE_AVAILABILITY',
|
||||
stage: 'QUALIFIED',
|
||||
stage: 'CONTACTED',
|
||||
areaLabel: '~600 m²',
|
||||
availabilityLabel: '~10 Monate',
|
||||
addedAt: '2026-05-06T10:00:00Z',
|
||||
@@ -121,7 +121,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
||||
title: 'Gewerbe Güterstrasse Basel',
|
||||
location: 'Basel',
|
||||
matchScore: 68,
|
||||
resultType: 'EXTERNAL_MARKET',
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
stage: 'CLOSED_LOST',
|
||||
areaLabel: '800 m²',
|
||||
rentLabel: 'CHF 28/m²',
|
||||
|
||||
+44
-23
@@ -1,5 +1,6 @@
|
||||
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
||||
import type { Property } from '../domain/property'
|
||||
import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
|
||||
|
||||
export const mockProperties: Property[] = [
|
||||
|
||||
@@ -1071,14 +1072,14 @@ export const mockProperties: Property[] = [
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EXTERNAL_MARKET (10)
|
||||
// VERIFIED_PORTFOLIO — formerly external market (migrated)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'prop-003',
|
||||
title: 'Retail-Fläche Bahnhofstrasse 88',
|
||||
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 } },
|
||||
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 320,
|
||||
@@ -1122,7 +1123,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-004',
|
||||
title: 'Gemischte Gewerbeeinheit Europaallee',
|
||||
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 } },
|
||||
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 1150,
|
||||
@@ -1246,7 +1247,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-017',
|
||||
title: 'Ladenfläche Löwenstrasse 28',
|
||||
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 } },
|
||||
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 350,
|
||||
@@ -1290,7 +1291,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-018',
|
||||
title: 'Bürofläche Breitenrain 14',
|
||||
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 } },
|
||||
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 780,
|
||||
@@ -1458,7 +1459,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-022',
|
||||
title: 'Bürofläche St.Gallen Centrum 7',
|
||||
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 } },
|
||||
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
|
||||
areaSqm: 700,
|
||||
@@ -1666,7 +1667,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-031',
|
||||
title: 'Bürofläche Hardturm West 16',
|
||||
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 } },
|
||||
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 780,
|
||||
@@ -1780,7 +1781,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-034',
|
||||
title: 'Ladenfläche Lorrainestrasse 8',
|
||||
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 } },
|
||||
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 340,
|
||||
@@ -1844,7 +1845,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-036',
|
||||
title: 'Lagerhalle Klybeckstrasse 280',
|
||||
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 } },
|
||||
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||
areaSqm: 2600,
|
||||
@@ -2206,7 +2207,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-055',
|
||||
title: 'Ladenfläche Weststrasse 44',
|
||||
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 } },
|
||||
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 150,
|
||||
@@ -2254,7 +2255,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-056',
|
||||
title: 'Gewerbefläche Hohlstrasse 88',
|
||||
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 } },
|
||||
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 155,
|
||||
@@ -2667,7 +2668,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-064',
|
||||
title: 'Bürofläche Förrlibuckstrasse 22',
|
||||
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 } },
|
||||
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 185,
|
||||
@@ -2714,7 +2715,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-065',
|
||||
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
|
||||
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 } },
|
||||
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 175,
|
||||
@@ -2813,7 +2814,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-067',
|
||||
title: 'Bürofläche Altstetterstrasse 222',
|
||||
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 } },
|
||||
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 190,
|
||||
@@ -3175,7 +3176,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-074',
|
||||
title: 'Bürofläche Uraniastrasse 14',
|
||||
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 } },
|
||||
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 510,
|
||||
@@ -3223,7 +3224,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-075',
|
||||
title: 'Bürofläche Seefeldstrasse 30',
|
||||
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 } },
|
||||
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 540,
|
||||
@@ -3483,7 +3484,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-080',
|
||||
title: 'Ladenfläche Spitalgasse 34',
|
||||
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 } },
|
||||
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 280,
|
||||
@@ -3531,7 +3532,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-081',
|
||||
title: 'Ladenfläche Münstergasse 12',
|
||||
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 } },
|
||||
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 350,
|
||||
@@ -3838,7 +3839,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-087',
|
||||
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
|
||||
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 } },
|
||||
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
||||
areaSqm: 3000,
|
||||
@@ -3885,7 +3886,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-088',
|
||||
title: 'Lagerhalle Westquaistrasse 12',
|
||||
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 } },
|
||||
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||
areaSqm: 2600,
|
||||
@@ -4091,7 +4092,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-092',
|
||||
title: 'Bürofläche Hohlstrasse 534, Altstetten',
|
||||
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 } },
|
||||
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 900,
|
||||
@@ -4138,7 +4139,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-093',
|
||||
title: 'Bürofläche Technopark Zürich-West',
|
||||
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 } },
|
||||
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 960,
|
||||
@@ -4343,7 +4344,7 @@ export const mockProperties: Property[] = [
|
||||
id: 'prop-097',
|
||||
title: 'Bürofläche Baarerstrasse 18, Zug',
|
||||
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 } },
|
||||
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
|
||||
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,
|
||||
} from '@mui/material'
|
||||
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 { useToastStore } from '../../stores/toastStore'
|
||||
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 { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,9 +36,13 @@ export default function Anfragen() {
|
||||
|
||||
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>(
|
||||
preselectedId ?? mockInquiries[0]?.id ?? null
|
||||
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
||||
)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
@@ -45,18 +51,21 @@ export default function Anfragen() {
|
||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
||||
const threadRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = inquiries.filter(inq => {
|
||||
const filtered = allInquiries.filter(inq => {
|
||||
const q = search.toLowerCase()
|
||||
const matchesSearch = !q ||
|
||||
inq.tenantName.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
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const selected = inquiries.find(i => i.id === selectedId) ?? null
|
||||
const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||
|
||||
// Pipeline link for currently selected inquiry
|
||||
const linkedPipelineItem = selected?.propertyId
|
||||
@@ -176,6 +185,7 @@ export default function Anfragen() {
|
||||
inq={inq}
|
||||
isSelected={inq.id === selectedId}
|
||||
hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
|
||||
perspective="demand"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -205,12 +215,16 @@ export default function Anfragen() {
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<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>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.tenantName}</Typography>
|
||||
{selected.tenantCompany && <Typography variant="caption" color="text.secondary">{selected.tenantCompany}</Typography>}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.propertyAddress ?? selected.subject}</Typography>
|
||||
{(selected.propertyManagerCompany ?? selected.propertyManagerName) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selected.propertyManagerCompany ?? selected.propertyManagerName ?? ''}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
||||
{selected.subject}
|
||||
@@ -281,7 +295,7 @@ export default function Anfragen() {
|
||||
{/* Thread */}
|
||||
<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 => (
|
||||
<AnfragenMessageBubble key={msg.id} msg={msg} />
|
||||
<AnfragenMessageBubble key={msg.id} msg={msg} currentUserName={currentUser?.name ?? undefined} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { PropertyMap } from '../../components/shared'
|
||||
@@ -46,6 +48,7 @@ export default function MatchDetail() {
|
||||
const navigate = useNavigate()
|
||||
const { addToCompare } = useCompareStore()
|
||||
const { openSavedDialog } = usePipelineStore()
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
@@ -88,7 +91,7 @@ export default function MatchDetail() {
|
||||
const handleCompare = () => {
|
||||
if (match && !isFuture && property) {
|
||||
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,
|
||||
})
|
||||
} else if (match && isFuture && signal) {
|
||||
@@ -126,6 +129,7 @@ export default function MatchDetail() {
|
||||
return (
|
||||
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
||||
<AddToPipelineDialog />
|
||||
<InquiryQuickDialog />
|
||||
|
||||
{/* Sticky back nav */}
|
||||
<Box sx={{
|
||||
@@ -164,7 +168,7 @@ export default function MatchDetail() {
|
||||
/>
|
||||
|
||||
{/* ── 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? ── */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
@@ -196,9 +200,14 @@ export default function MatchDetail() {
|
||||
<NextActionsPanel
|
||||
match={match}
|
||||
onCompare={handleCompare}
|
||||
onShortlist={handleShortlist}
|
||||
onReview={() => {}}
|
||||
onReject={() => {}}
|
||||
onPipeline={handleShortlist}
|
||||
onInquire={() => openInquiryDialog({
|
||||
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 ── */}
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
||||
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
||||
@@ -22,6 +24,7 @@ export default function Pipeline() {
|
||||
const navigate = useNavigate()
|
||||
const { data: items = [] } = usePipelineItems()
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [overId, setOverId] = useState<string | null>(null)
|
||||
@@ -66,6 +69,7 @@ export default function Pipeline() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<AddToPipelineDialog />
|
||||
<InquiryQuickDialog />
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
@@ -123,6 +127,12 @@ export default function Pipeline() {
|
||||
selectedId={syncedSelected?.id ?? null}
|
||||
onSelect={handleSelect}
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
UnifiedResultFeed,
|
||||
} from '../../components/results'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import { ErrorState } from '../../components/ui'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
@@ -65,7 +66,8 @@ export default function Results() {
|
||||
}
|
||||
}, [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'
|
||||
|
||||
@@ -73,7 +75,7 @@ export default function Results() {
|
||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
||||
let platform = 0, maison = 0, future = 0, missing = 0
|
||||
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 === 'FUTURE_AVAILABILITY') future++
|
||||
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'
|
||||
}
|
||||
if (filterSource === 'ALL') return true
|
||||
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
|
||||
return r.resultType === filterSource
|
||||
})
|
||||
return sortResults(filtered, sortBy)
|
||||
@@ -102,6 +103,7 @@ export default function Results() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<AddToPipelineDialog />
|
||||
<InquiryQuickDialog />
|
||||
<ResultFeedHeader
|
||||
total={sorted.length}
|
||||
platformCount={platformCount}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { PipelineStage } from '../../domain/pipeline'
|
||||
|
||||
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> = {
|
||||
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
|
||||
SAVED: 'Interessiert', CONTACTED: 'Angefragt',
|
||||
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> = {
|
||||
OFFICE: 'Büro',
|
||||
RETAIL: 'Einzelhandel',
|
||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
LOGISTICS: 'Logistik',
|
||||
PRODUCTION: 'Produktion',
|
||||
MIXED: 'Gemischt',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SoftFactorsSection,
|
||||
TechnicalDetailsSection,
|
||||
ImageUrlSection,
|
||||
FloorPlanUrlSection,
|
||||
ContactSection,
|
||||
CreatedScreen,
|
||||
} from '../../components/new-listing'
|
||||
@@ -92,6 +93,11 @@ export default function NewListing() {
|
||||
onRemove={form.removeImage}
|
||||
/>
|
||||
|
||||
<FloorPlanUrlSection
|
||||
floorPlanUrl={form.floorPlanUrl}
|
||||
onFloorPlanUrlChange={form.setFloorPlanUrl}
|
||||
/>
|
||||
|
||||
<ContactSection
|
||||
name={form.contactName} onNameChange={form.setContactName}
|
||||
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro',
|
||||
RETAIL: 'Einzelhandel',
|
||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
LOGISTICS: 'Logistik',
|
||||
PRODUCTION: 'Produktion',
|
||||
MIXED: 'Gemischt',
|
||||
|
||||
@@ -18,11 +18,12 @@ export function buildCreatePropertyInput(fields: {
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
images: string[]
|
||||
floorPlanUrl: string
|
||||
}): CreatePropertyInput {
|
||||
const {
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm, rentPerSqm, availableFrom, description,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, images,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
} = fields
|
||||
|
||||
const sf = {
|
||||
@@ -47,7 +48,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
return {
|
||||
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
|
||||
assetType: assetType as typeof AssetType[keyof typeof AssetType],
|
||||
resultType: ResultType.EXTERNAL_MARKET,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
sourceType: 'DIRECT',
|
||||
location: { city, country: 'CH' },
|
||||
address: {
|
||||
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
softFactors: sf,
|
||||
hardFacts: hf,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
floorPlanUrl: floorPlanUrl.trim() || undefined,
|
||||
dataQuality: {
|
||||
score: 1.0,
|
||||
missingCriticalFields: [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface DashboardStats {
|
||||
totalProperties: number
|
||||
verifiedProperties: number
|
||||
externalMarketProperties: number
|
||||
verifiedPortfolioProperties: number
|
||||
futureSignalProperties: number
|
||||
totalMatches: number
|
||||
pendingReviews: number
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MockupDashboardProvider: IDashboardProvider = {
|
||||
const stats: DashboardStats = {
|
||||
totalProperties: props.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,
|
||||
totalMatches: matches.length,
|
||||
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||
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[] = []
|
||||
const store = matchStore
|
||||
|
||||
@@ -13,6 +13,9 @@ export const MockupMatchProvider: IMatchProvider = {
|
||||
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
||||
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
||||
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)
|
||||
},
|
||||
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) {
|
||||
generateMatchesForNeed(need)
|
||||
syncMatchesForNeed(need)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,22 @@ import { mockPipelineItems } from '../mock-data/pipelineItems'
|
||||
|
||||
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[] {
|
||||
try {
|
||||
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 */ }
|
||||
return [...mockPipelineItems]
|
||||
}
|
||||
|
||||
@@ -60,8 +60,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
|
||||
|
||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||
const ZURICH_ONLY_SIGNALS = [
|
||||
'industrie groove', 'industrie-groove',
|
||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
||||
'pfingstweidstrasse', 'freilager', 'europaallee',
|
||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||
'hürlimann', 'viadukt', 'schiffbau',
|
||||
]
|
||||
|
||||
@@ -32,7 +32,7 @@ function buildMatch(
|
||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: `m__${prop.id}__${unitId ?? 'prop'}__${need.id}`,
|
||||
propertyId: prop.id,
|
||||
unitId,
|
||||
needId: need.id,
|
||||
@@ -92,10 +92,13 @@ export function generateMatchesForNeed(need: Need): void {
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
const allPreMarket = prop.units!.every(u => u.schattenmarktRelease?.enabled)
|
||||
if (!allPreMarket) {
|
||||
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!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
|
||||
@@ -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