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:
Benjamin Sutter
2026-05-24 22:12:43 +02:00
parent 3ab6eeccf2
commit e95490eb72
39 changed files with 685 additions and 147 deletions
@@ -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',
} }
+1 -1
View File
@@ -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']
+1 -1
View File
@@ -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"
+187 -90
View File
@@ -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>
)
}
+1
View File
@@ -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'
+57 -4
View File
@@ -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>
) )
} }
+8 -10
View File
@@ -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' },
} }
+3 -3
View File
@@ -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
} }
+29 -1
View File
@@ -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} />
} }
+39 -4
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1
View File
@@ -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[]
+1
View File
@@ -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
+7 -4
View File
@@ -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,
} }
+2 -2
View File
@@ -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
+1
View File
@@ -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',
} }
+144
View File
@@ -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 }
}
+2 -2
View File
@@ -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',
+21
View File
@@ -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,
})]
})
+3 -1
View File
@@ -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>
+2 -1
View File
@@ -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'
+2 -2
View File
@@ -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',
} }
+1 -1
View File
@@ -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',
+6
View File
@@ -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 -1
View File
@@ -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',
+3 -1
View File
@@ -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: [],
+4 -1
View File
@@ -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) {
+2 -2
View File
@@ -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)
} }
+13 -1
View File
@@ -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]
} }
+7 -4
View File
@@ -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
+1
View File
@@ -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 {