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 { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
@@ -53,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', top: 10, left: 10,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
background: theme.gradient, borderRadius: '10px',
|
||||||
@@ -142,7 +144,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|||||||
@@ -1,119 +1,216 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||||
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
mb: 1.5,
|
||||||
mb: 1.5,
|
borderRadius: 2,
|
||||||
border: `2px solid ${borderColor}`,
|
overflow: 'hidden',
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
border: `2px solid ${borderColor}`,
|
||||||
transition: 'border-color 0.15s',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||||
}}
|
background: theme.cardBg,
|
||||||
>
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
transition: 'box-shadow 0.15s',
|
||||||
{vm.disclaimer && (
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
}}>
|
||||||
{vm.disclaimer}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
{/* ── Image strip ── */}
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||||
Manuelle Überprüfung erforderlich
|
{imageUrl ? (
|
||||||
</Alert>
|
<img
|
||||||
)}
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 120,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#7ba3bf" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
{/* Score badge */}
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Box sx={{
|
||||||
|
position: 'absolute', top: 8, left: 8,
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1, py: 0.25,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Title + location (max 2 lines) */}
|
{/* Asset type label */}
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
{overlayTypeLabel && (
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
<Box sx={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
px: 0.75, py: 0.5,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{vm.disclaimer && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Badges row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={rt.label} size="small"
|
||||||
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
|
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||||
|
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||||
|
)}
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||||
|
)}
|
||||||
|
{topRisk && (
|
||||||
|
<Chip
|
||||||
|
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||||
|
size="small" color="error" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title + location */}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||||
<MapPin size={13} color="#64748b" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{vm.locationLabel}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||||
variant="body2"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* Score breakdown */}
|
||||||
|
{vm.scoreBreakdown && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasons — all 3, same style as grid card */}
|
||||||
|
{vm.reasons.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top tradeoff */}
|
||||||
|
{vm.tradeoffs.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
|
{vm.tradeoffs[0].description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button size="small" variant="contained" onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick} disabled={a.disabled}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
|
||||||
|
|
||||||
{/* Score formula — always visible */}
|
|
||||||
{vm.scoreBreakdown && (
|
|
||||||
<Box sx={{ mb: 1.25 }}>
|
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top reason only */}
|
|
||||||
{vm.reasons.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
|
||||||
{vm.tradeoffs.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
|
||||||
<MatchDataQualitySummary
|
|
||||||
dataQualityScore={vm.dataQualityScore}
|
|
||||||
missingData={vm.missingData}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -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 { Send } from 'lucide-react'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
@@ -19,6 +20,7 @@ export function InquiryQuickDialog() {
|
|||||||
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
const addInquiry = useInquiryStore(s => s.addInquiry)
|
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||||
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
@@ -31,6 +33,9 @@ export function InquiryQuickDialog() {
|
|||||||
function handleSend() {
|
function handleSend() {
|
||||||
if (!pendingInquiry) return
|
if (!pendingInquiry) return
|
||||||
addInquiry(pendingInquiry, message)
|
addInquiry(pendingInquiry, message)
|
||||||
|
if (pendingInquiry.pipelineItemId) {
|
||||||
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
|
}
|
||||||
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||||
closeInquiryDialog()
|
closeInquiryDialog()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||||
|
|
||||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grundriss */}
|
||||||
|
{(() => {
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
if (matchedUnit?.floorPlanUrl) {
|
||||||
|
const lbl = matchedUnit.unitLabel
|
||||||
|
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||||
|
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||||
|
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||||
|
} else {
|
||||||
|
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.dataQuality.lastVerifiedAt && (
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import {
|
import {
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
UnitRow,
|
UnitRow,
|
||||||
} from './MatchDetailPropertyDetails'
|
} from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
interface PropertyDetailPublicSectionsProps {
|
interface PropertyDetailPublicSectionsProps {
|
||||||
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Grundriss ── */}
|
||||||
|
{(() => {
|
||||||
|
const allUnits = property.units ?? []
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} mb={2} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Beschreibung ── */}
|
{/* ── Beschreibung ── */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
@@ -270,7 +284,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floorPlanUrl: string
|
||||||
|
onFloorPlanUrlChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Grundriss-URL"
|
||||||
|
value={floorPlanUrl}
|
||||||
|
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
{floorPlanUrl && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={floorPlanUrl}
|
||||||
|
alt="Grundriss Vorschau"
|
||||||
|
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
|||||||
export { ImageUrlSection } from './ImageUrlSection'
|
export { ImageUrlSection } from './ImageUrlSection'
|
||||||
export { ContactSection } from './ContactSection'
|
export { ContactSection } from './ContactSection'
|
||||||
export { CreatedScreen } from './CreatedScreen'
|
export { CreatedScreen } from './CreatedScreen'
|
||||||
|
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import type { PipelineItem } from '../../domain/pipeline'
|
import type { PipelineItem } from '../../domain/pipeline'
|
||||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
import { detailPath } from './pipelineUtils'
|
import { detailPath } from './pipelineUtils'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
|
||||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
|||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||||
const path = detailPath(item)
|
const path = detailPath(item)
|
||||||
|
|
||||||
const style = !isDragOverlay ? {
|
const style = !isDragOverlay ? {
|
||||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip title="Objekt öffnen">
|
<Tooltip title="Match öffnen">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
|||||||
{item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Anfrage / Konversation */}
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MessagesSquare size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Konversation
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Send size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.propertyAddress ?? item.location,
|
||||||
|
areaLabel: item.areaLabel,
|
||||||
|
rentLabel: item.rentLabel,
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
pipelineItemId: item.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
|||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
||||||
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
|
||||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
|
|||||||
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
||||||
|
|
||||||
export function detailPath(item: PipelineItem): string | null {
|
export function detailPath(item: PipelineItem): string | null {
|
||||||
// propertyId is always stable across sessions — prefer it
|
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
|
||||||
|
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
|
||||||
|
if (isStableMatchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
||||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
|
||||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
|
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
? result.property.location.city
|
? result.property.location.city
|
||||||
: result.signal.locationHint ?? undefined
|
: result.signal.locationHint ?? undefined
|
||||||
|
|
||||||
|
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntelligenceMatchCard
|
<IntelligenceMatchCard
|
||||||
vm={vm}
|
vm={vm}
|
||||||
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
lat={lat}
|
lat={lat}
|
||||||
lng={lng}
|
lng={lng}
|
||||||
cityLabel={cityLabel}
|
cityLabel={cityLabel}
|
||||||
|
overlayTypeLabel={overlayLabels?.type}
|
||||||
|
overlayLocationLabel={overlayLabels?.location}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MatchCardCompact vm={vm} />
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? result.property.images?.[0]
|
||||||
|
: undefined
|
||||||
|
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace placeholder with Google Maps Static API:
|
|
||||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
|
||||||
|
|
||||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||||
@@ -24,7 +23,7 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
|||||||
return 'https://www.google.com/maps'
|
return 'https://www.google.com/maps'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||||
const showImage = !!imageUrl && !imgError
|
const showImage = !!imageUrl && !imgError
|
||||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom-left overlay: type + location */}
|
||||||
|
{(overlayTypeLabel || overlayLocationLabel) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.68rem', fontWeight: 700, color: 'white',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
|
||||||
|
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{overlayLocationLabel && (
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
|
||||||
|
{overlayLocationLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Google Maps button */}
|
{/* Google Maps button */}
|
||||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
|||||||
GASTRO: 'Gastro',
|
GASTRO: 'Gastro',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
UNKNOWN: 'Unbekannt',
|
UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
return labels[type] ?? type
|
return labels[type] ?? type
|
||||||
|
|||||||
@@ -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 {
|
export interface PipelineItem {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface Property {
|
|||||||
riskLevel?: RiskLevel
|
riskLevel?: RiskLevel
|
||||||
description?: string
|
description?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
|
floorPlanUrl?: string
|
||||||
|
|
||||||
propertyNumber?: string
|
propertyNumber?: string
|
||||||
units?: PropertyUnit[]
|
units?: PropertyUnit[]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PropertyUnit {
|
|||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
leaseTerm?: string
|
leaseTerm?: string
|
||||||
leaseEndDate?: string
|
leaseEndDate?: string
|
||||||
|
floorPlanUrl?: string // optional floor plan image
|
||||||
// Flexible letting (Teilfläche)
|
// Flexible letting (Teilfläche)
|
||||||
isFlexible?: boolean
|
isFlexible?: boolean
|
||||||
minLettableSqm?: number
|
minLettableSqm?: number
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface NewListingFormState {
|
|||||||
// Images
|
// Images
|
||||||
images: string[]
|
images: string[]
|
||||||
imageInput: string
|
imageInput: string
|
||||||
|
floorPlanUrl: string
|
||||||
// AI
|
// AI
|
||||||
aiText: string
|
aiText: string
|
||||||
aiApplied: boolean
|
aiApplied: boolean
|
||||||
@@ -82,6 +83,7 @@ export interface NewListingFormHandlers {
|
|||||||
setContactEmail: (v: string) => void
|
setContactEmail: (v: string) => void
|
||||||
setContactPhone: (v: string) => void
|
setContactPhone: (v: string) => void
|
||||||
setImageInput: (v: string) => void
|
setImageInput: (v: string) => void
|
||||||
|
setFloorPlanUrl: (v: string) => void
|
||||||
setAiText: (v: string) => void
|
setAiText: (v: string) => void
|
||||||
addImage: () => void
|
addImage: () => void
|
||||||
removeImage: (index: number) => void
|
removeImage: (index: number) => void
|
||||||
@@ -113,6 +115,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [imageInput, setImageInput] = useState('')
|
const [imageInput, setImageInput] = useState('')
|
||||||
|
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||||
const [aiText, setAiText] = useState('')
|
const [aiText, setAiText] = useState('')
|
||||||
const [aiApplied, setAiApplied] = useState(false)
|
const [aiApplied, setAiApplied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -157,7 +160,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||||
availableFrom, description, softLevels,
|
availableFrom, description, softLevels,
|
||||||
floor, fitOut, parking, ceilingHeight, images,
|
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onSuccess: () => setCreated(true),
|
onSuccess: () => setCreated(true),
|
||||||
@@ -173,7 +176,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||||
setSoftLevels(emptySoftLevels())
|
setSoftLevels(emptySoftLevels())
|
||||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||||
setImages([]); setImageInput('')
|
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||||
setAiText(''); setAiApplied(false)
|
setAiText(''); setAiApplied(false)
|
||||||
setCreated(false); setError(null)
|
setCreated(false); setError(null)
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
street, houseNumber, postalCode, city,
|
street, houseNumber, postalCode, city,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||||
contactName, contactEmail, contactPhone,
|
contactName, contactEmail, contactPhone,
|
||||||
images, imageInput, aiText, aiApplied,
|
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||||
error, created,
|
error, created,
|
||||||
aiParsing: parseListingMutation.isPending,
|
aiParsing: parseListingMutation.isPending,
|
||||||
submitting: createProperty.isPending,
|
submitting: createProperty.isPending,
|
||||||
@@ -193,7 +196,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setSoftLevel,
|
setSoftLevel,
|
||||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||||
setContactName, setContactEmail, setContactPhone,
|
setContactName, setContactEmail, setContactPhone,
|
||||||
setImageInput, setAiText,
|
setImageInput, setFloorPlanUrl, setAiText,
|
||||||
addImage, removeImage,
|
addImage, removeImage,
|
||||||
handleAiParse, handleSubmit, resetForm,
|
handleAiParse, handleSubmit, resetForm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import type {
|
|||||||
} from '../domain/unifiedResult'
|
} from '../domain/unifiedResult'
|
||||||
|
|
||||||
export function useUnifiedResults(needId?: string) {
|
export function useUnifiedResults(needId?: string) {
|
||||||
const allMatchesQuery = useMatches()
|
|
||||||
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
||||||
const propertiesQuery = useProperties()
|
const propertiesQuery = useProperties()
|
||||||
const signalsQuery = useFutureSignals()
|
const signalsQuery = useFutureSignals()
|
||||||
|
|
||||||
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
|
// Always scope to a specific need — showing cross-need results causes duplicates per property
|
||||||
|
const matchesQuery = needMatchesQuery
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
RETAIL: 'Retail',
|
RETAIL: 'Retail',
|
||||||
GASTRO: 'Gastronomie',
|
GASTRO: 'Gastronomie',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
location: 'Zürich-Oerlikon',
|
||||||
matchScore: 87,
|
matchScore: 87,
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '1200 m²',
|
areaLabel: '1200 m²',
|
||||||
rentLabel: 'CHF 38/m²',
|
rentLabel: 'CHF 38/m²',
|
||||||
availabilityLabel: '2026-10-01',
|
availabilityLabel: '2026-10-01',
|
||||||
@@ -74,7 +74,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
location: 'Zürich-West / Technopark',
|
location: 'Zürich-West / Technopark',
|
||||||
matchScore: 76,
|
matchScore: 76,
|
||||||
resultType: 'FUTURE_AVAILABILITY',
|
resultType: 'FUTURE_AVAILABILITY',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '~600 m²',
|
areaLabel: '~600 m²',
|
||||||
availabilityLabel: '~10 Monate',
|
availabilityLabel: '~10 Monate',
|
||||||
addedAt: '2026-05-06T10:00:00Z',
|
addedAt: '2026-05-06T10:00:00Z',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
|
||||||
|
|
||||||
export const mockProperties: Property[] = [
|
export const mockProperties: Property[] = [
|
||||||
|
|
||||||
@@ -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 { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||||
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||||
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export default function Anfragen() {
|
|||||||
|
|
||||||
const preselectedId = searchParams.get('inquiry')
|
const preselectedId = searchParams.get('inquiry')
|
||||||
|
|
||||||
|
const { currentUser } = useSessionStore()
|
||||||
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||||
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
||||||
const allInquiries = [...storeInquiries, ...inquiries]
|
const allInquiries = [...storeInquiries, ...inquiries]
|
||||||
@@ -293,7 +295,7 @@ export default function Anfragen() {
|
|||||||
{/* Thread */}
|
{/* Thread */}
|
||||||
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{selected.thread.map(msg => (
|
{selected.thread.map(msg => (
|
||||||
<AnfragenMessageBubble key={msg.id} msg={msg} />
|
<AnfragenMessageBubble key={msg.id} msg={msg} currentUserName={currentUser?.name ?? undefined} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export default function Results() {
|
|||||||
}
|
}
|
||||||
}, [activeNeedIdFromNav, queryClient])
|
}, [activeNeedIdFromNav, queryClient])
|
||||||
|
|
||||||
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
|
const needsLoading = allNeeds.length === 0 && !activeNeedIdFromNav
|
||||||
|
const { data: results = [], isLoading, error } = useUnifiedResults(needsLoading ? undefined : effectiveNeedId)
|
||||||
|
|
||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { PipelineStage } from '../../domain/pipeline'
|
import type { PipelineStage } from '../../domain/pipeline'
|
||||||
|
|
||||||
export const STAGE_ORDER: PipelineStage[] = [
|
export const STAGE_ORDER: PipelineStage[] = [
|
||||||
'SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
|
'SAVED', 'CONTACTED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const STAGE_LABELS: Record<string, string> = {
|
export const STAGE_LABELS: Record<string, string> = {
|
||||||
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
|
SAVED: 'Interessiert', CONTACTED: 'Angefragt',
|
||||||
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
|
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type { Property } from '../../domain/property'
|
|||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro',
|
OFFICE: 'Büro',
|
||||||
RETAIL: 'Einzelhandel',
|
RETAIL: 'Einzelhandel',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SoftFactorsSection,
|
SoftFactorsSection,
|
||||||
TechnicalDetailsSection,
|
TechnicalDetailsSection,
|
||||||
ImageUrlSection,
|
ImageUrlSection,
|
||||||
|
FloorPlanUrlSection,
|
||||||
ContactSection,
|
ContactSection,
|
||||||
CreatedScreen,
|
CreatedScreen,
|
||||||
} from '../../components/new-listing'
|
} from '../../components/new-listing'
|
||||||
@@ -92,6 +93,11 @@ export default function NewListing() {
|
|||||||
onRemove={form.removeImage}
|
onRemove={form.removeImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FloorPlanUrlSection
|
||||||
|
floorPlanUrl={form.floorPlanUrl}
|
||||||
|
onFloorPlanUrlChange={form.setFloorPlanUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
<ContactSection
|
<ContactSection
|
||||||
name={form.contactName} onNameChange={form.setContactName}
|
name={form.contactName} onNameChange={form.setContactName}
|
||||||
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro',
|
OFFICE: 'Büro',
|
||||||
RETAIL: 'Einzelhandel',
|
RETAIL: 'Einzelhandel',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export function buildCreatePropertyInput(fields: {
|
|||||||
parking: string
|
parking: string
|
||||||
ceilingHeight: string
|
ceilingHeight: string
|
||||||
images: string[]
|
images: string[]
|
||||||
|
floorPlanUrl: string
|
||||||
}): CreatePropertyInput {
|
}): CreatePropertyInput {
|
||||||
const {
|
const {
|
||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm, rentPerSqm, availableFrom, description,
|
areaSqm, rentPerSqm, availableFrom, description,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight, images,
|
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
} = fields
|
} = fields
|
||||||
|
|
||||||
const sf = {
|
const sf = {
|
||||||
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
|
|||||||
softFactors: sf,
|
softFactors: sf,
|
||||||
hardFacts: hf,
|
hardFacts: hf,
|
||||||
images: images.length > 0 ? images : undefined,
|
images: images.length > 0 ? images : undefined,
|
||||||
|
floorPlanUrl: floorPlanUrl.trim() || undefined,
|
||||||
dataQuality: {
|
dataQuality: {
|
||||||
score: 1.0,
|
score: 1.0,
|
||||||
missingCriticalFields: [],
|
missingCriticalFields: [],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||||
import type { Match } from '../domain/match'
|
import type { Match } from '../domain/match'
|
||||||
|
|
||||||
// Matches are computed dynamically via calculateScore so weights always reflect need.weightingProfile
|
// Populated at startup by MockupNeedProvider via syncMatchesForNeed (deterministic IDs, no duplicates)
|
||||||
export const matchStore: Match[] = []
|
export const matchStore: Match[] = []
|
||||||
const store = matchStore
|
const store = matchStore
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export const MockupMatchProvider: IMatchProvider = {
|
|||||||
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
||||||
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
||||||
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
|
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
|
||||||
|
// Deduplicate by id — guards against double-push during dev hot-reload
|
||||||
|
const seen = new Set<string>()
|
||||||
|
results = results.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true })
|
||||||
return results.sort((a, b) => b.matchScore - a.matchScore)
|
return results.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
},
|
},
|
||||||
async getById(id) {
|
async getById(id) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const MockupNeedProvider: INeedProvider = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
// Recompute matches for all pre-existing needs — replaces static mock matches so scores reflect need.weightingProfile
|
||||||
for (const need of store) {
|
for (const need of store) {
|
||||||
generateMatchesForNeed(need)
|
syncMatchesForNeed(need)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,22 @@ import { mockPipelineItems } from '../mock-data/pipelineItems'
|
|||||||
|
|
||||||
const STORAGE_KEY = 'property_match_pipeline_items'
|
const STORAGE_KEY = 'property_match_pipeline_items'
|
||||||
|
|
||||||
|
const STAGE_MIGRATION: Record<string, PipelineStage> = {
|
||||||
|
DISCOVERED: 'CONTACTED',
|
||||||
|
QUALIFIED: 'CONTACTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStage(stage: string): PipelineStage {
|
||||||
|
return (STAGE_MIGRATION[stage] ?? stage) as PipelineStage
|
||||||
|
}
|
||||||
|
|
||||||
function load(): PipelineItem[] {
|
function load(): PipelineItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) return JSON.parse(raw) as PipelineItem[]
|
if (raw) {
|
||||||
|
const items = JSON.parse(raw) as PipelineItem[]
|
||||||
|
return items.map(i => ({ ...i, stage: migrateStage(i.stage) }))
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [...mockPipelineItems]
|
return [...mockPipelineItems]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function buildMatch(
|
|||||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: `m__${prop.id}__${unitId ?? 'prop'}__${need.id}`,
|
||||||
propertyId: prop.id,
|
propertyId: prop.id,
|
||||||
unitId,
|
unitId,
|
||||||
needId: need.id,
|
needId: need.id,
|
||||||
@@ -92,9 +92,12 @@ export function generateMatchesForNeed(need: Need): void {
|
|||||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||||
|
|
||||||
if (hasExplicitUnits) {
|
if (hasExplicitUnits) {
|
||||||
const output = scoreProperty(need, prop)
|
const allPreMarket = prop.units!.every(u => u.schattenmarktRelease?.enabled)
|
||||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
if (!allPreMarket) {
|
||||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
const output = scoreProperty(need, prop)
|
||||||
|
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||||
|
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const unit of prop.units!) {
|
for (const unit of prop.units!) {
|
||||||
if (!unit.schattenmarktRelease?.enabled) continue
|
if (!unit.schattenmarktRelease?.enabled) continue
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PendingInquiry {
|
|||||||
matchScore: number
|
matchScore: number
|
||||||
matchId?: string
|
matchId?: string
|
||||||
propertyId?: string
|
propertyId?: string
|
||||||
|
pipelineItemId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InquiryStore {
|
interface InquiryStore {
|
||||||
|
|||||||
Reference in New Issue
Block a user