feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?; FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular - Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen, Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button - Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel, newListingConstants, MyListings, propertyHelpers) - "Zum Originalinserat"-Button nur bei Maison-Work-Objekten - Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder), nur Innenaufnahmen, rotate()-Trick für Sub-Pools - Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ 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()
|
||||
@@ -53,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',
|
||||
@@ -142,7 +144,7 @@ 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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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 {
|
||||
@@ -19,6 +20,7 @@ export function InquiryQuickDialog() {
|
||||
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('')
|
||||
|
||||
@@ -31,6 +33,9 @@ export function InquiryQuickDialog() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Property {
|
||||
riskLevel?: RiskLevel
|
||||
description?: string
|
||||
images?: string[]
|
||||
floorPlanUrl?: string
|
||||
|
||||
propertyNumber?: string
|
||||
units?: PropertyUnit[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import type {
|
||||
} 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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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,
|
||||
})]
|
||||
})
|
||||
|
||||
@@ -14,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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function Anfragen() {
|
||||
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
||||
const allInquiries = [...storeInquiries, ...inquiries]
|
||||
@@ -293,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>
|
||||
|
||||
|
||||
@@ -66,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'
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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 @@
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface PendingInquiry {
|
||||
matchScore: number
|
||||
matchId?: string
|
||||
propertyId?: string
|
||||
pipelineItemId?: string
|
||||
}
|
||||
|
||||
interface InquiryStore {
|
||||
|
||||
Reference in New Issue
Block a user