5 Commits

Author SHA1 Message Date
Benjamin Sutter e95490eb72 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>
2026-05-24 22:12:43 +02:00
Benjamin Sutter 3ab6eeccf2 fix: demand Anfragen page shows own sent inquiries (demand perspective)
Replace supply-side mock data with demand-side conversations where the
logged-in user is the sender (right/dark) and verwalter replies are left.
List shows property name instead of tenant name.
New domain fields: propertyManagerName, propertyManagerCompany, propertyAddress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:07:59 +02:00
Benjamin Sutter 128d28af8d feat: in-memory inquiry flow — send from card, pipeline, and detail
New inquiryStore (Zustand) holds sent inquiries for the session.
InquiryQuickDialog reads from the store (no props), renders wherever needed.
Sent inquiries appear immediately at the top of the Anfragen page.

Entry points:
- Match cards: "Anfrage" button on every card in the results feed
- MatchDetail: primary action in NextActionsPanel
- Pipeline: chat icon on every DraggableCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:00:46 +02:00
Benjamin Sutter 9df5d285f5 fix: remove hidden score modifiers + fix Zürich auto-inference false positive
Score formula simplified to: finalScore = round(hard×60% + soft×40%)
Removes dataQualityModifier, confidenceModifier, mustHaveEval.scoreImpact —
the breakdown panel now always matches the displayed score.

Also removes 'industrie groove' from Zürich-only location signals — the phrase
describes an aesthetic preference, not a district reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:33:55 +02:00
Benjamin Sutter 609a3634bd feat: remove EXTERNAL_MARKET result type entirely
All external market properties now surface as VERIFIED_PORTFOLIO.
Removes the type from enums, domain types, mock data, matching engine,
components, hooks, and tests — zero user-visible distinction remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:28:42 +02:00
68 changed files with 1289 additions and 334 deletions
+3 -1
View File
@@ -11,7 +11,9 @@
"Bash(start http://localhost:5173)", "Bash(start http://localhost:5173)",
"Bash(npm install *)", "Bash(npm install *)",
"Bash(git pull *)", "Bash(git pull *)",
"Bash(node scripts/check-tokens.js)" "Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
] ]
} }
} }
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git commit -m ' *)", "Bash(git commit -m ' *)",
"Bash(git commit *)" "Bash(git commit *)",
"Bash(node -e ' *)"
] ]
} }
} }
+1 -2
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react' import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds' import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants' import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = { const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck, VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2, MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles, FUTURE_AVAILABILITY: Sparkles,
} }
-1
View File
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = { const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck }, VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 }, MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles }, FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
} }
+24 -5
View File
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
inq: Inquiry inq: Inquiry
isSelected: boolean isSelected: boolean
hasPipeline: boolean hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void onSelect: (id: string) => void
} }
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) { export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new'] const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1] const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger const unreadColor = cfg?.fg ?? DS_TEXT.danger
const primaryTitle = perspective === 'demand'
? (inq.propertyAddress ?? inq.subject)
: inq.tenantName
const secondaryLine = perspective === 'demand'
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
: (inq.tenantCompany ?? '')
const lastMsgPrefix = (() => {
if (!lastMsg) return ''
if (perspective === 'demand') {
return lastMsg.senderType === 'tenant'
? 'Sie: '
: `${inq.propertyManagerName ?? 'Verwalter'}: `
}
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
})()
return ( return (
<Box onClick={() => onSelect(inq.id)} sx={{ <Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5, px: 2, py: 1.5,
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />} {!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap> <Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName} {primaryTitle}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box> </Box>
</Box> </Box>
{inq.tenantCompany && ( {secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany} {secondaryLine}
</Typography> </Typography>
)} )}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap> <Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
</Typography> </Typography>
{lastMsg && ( {lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `} {lastMsgPrefix}
{lastMsg.body.split('\n')[0]} {lastMsg.body.split('\n')[0]}
</Typography> </Typography>
)} )}
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds' import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry' import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
const isOwnMessage = msg.senderType === 'tenant' const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai' const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />} {!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />} {!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time} {isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography> </Typography>
</Box> </Box>
<Box <Box
@@ -10,7 +10,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = { const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail', OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial', PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt', MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
} }
+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 },
] ]
-1
View File
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = { const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1', MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
@@ -4,6 +4,7 @@ import {
CheckCircle2, CheckCircle2,
} from 'lucide-react' } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview' import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge' import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
@@ -19,12 +20,15 @@ interface Props {
lat?: number lat?: number
lng?: number lng?: number
cityLabel?: string cityLabel?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
} }
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) { export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
const tier = getScoreTier(vm.matchScore) const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier] const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY' const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{/* ── Hero zone ── */} {/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} /> <LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<Box sx={{ <Box sx={{
position: 'absolute', top: 10, left: 10, position: 'absolute', top: 10, left: 10,
background: theme.gradient, borderRadius: '10px', background: theme.gradient, borderRadius: '10px',
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
)} )}
{/* Actions */} {/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage
</Button>
{vm.actions.map(a => ( {vm.actions.map(a => (
<Button <Button
key={a.id} key={a.id}
+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>
) )
} })
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
} }
@@ -0,0 +1,51 @@
import { Box, Paper, Typography } from '@mui/material'
import { FileImage } from 'lucide-react'
import { DS_TEXT } from '../../lib/ds'
interface FloorPlan {
url: string
label?: string
}
interface Props {
plans: FloorPlan[]
mb?: number | string
}
export function FloorPlanSection({ plans, mb }: Props) {
if (plans.length === 0) return null
return (
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FileImage size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{plans.map((p, i) => (
<Box key={i}>
{p.label && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
{p.label}
</Typography>
)}
<Box
component="img"
src={p.url}
alt={p.label ?? 'Grundriss'}
sx={{
width: '100%',
borderRadius: 1,
border: '1px solid rgba(0,0,0,0.08)',
display: 'block',
objectFit: 'contain',
maxHeight: 480,
bgcolor: '#f8fafc',
}}
/>
</Box>
))}
</Box>
</Paper>
)
}
@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Typography,
} from '@mui/material'
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
}
export function InquiryQuickDialog() {
const showToast = useToastStore(s => s.showToast)
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
useEffect(() => {
if (dialogOpen && pendingInquiry) {
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
}
}, [dialogOpen, pendingInquiry])
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
if (!pendingInquiry) return null
return (
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Send size={16} />
Anfrage senden
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{pendingInquiry.location}
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
{' · '}Match {pendingInquiry.matchScore}%
</Typography>
</Box>
<TextField
fullWidth
multiline
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
Der Text kann vor dem Absenden angepasst werden.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
<Button
onClick={handleSend}
variant="contained"
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
</DialogActions>
</Dialog>
)
}
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
@@ -1,8 +1,10 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds' import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']> type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
</Paper> </Paper>
)} )}
{/* Grundriss */}
{(() => {
const plans: Array<{ url: string; label?: string }> = []
if (matchedUnit?.floorPlanUrl) {
const lbl = matchedUnit.unitLabel
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
: FLOOR_LABEL(matchedUnit.floorLevel)
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
} else {
units.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
}
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} />
})()}
{/* Beschreibung */} {/* Beschreibung */}
{property.description && ( {property.description && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.dataQuality.lastVerifiedAt && ( {property.dataQuality.lastVerifiedAt && (
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} /> <KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
)} )}
{property.sourceUrl && ( {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}> <Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}> <Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat Zum Originalinserat
@@ -1,23 +1,17 @@
import { Box, Button, Paper, Typography } from '@mui/material' import { Box, Button, Paper, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { Match, NextBestAction } from '../../domain/match' import type { Match, NextBestAction } from '../../domain/match'
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 } const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
HIGH: 'contained',
MEDIUM: 'outlined',
LOW: 'outlined',
}
interface Props { interface Props {
match: Match match: Match
onCompare?: () => void onCompare?: () => void
onShortlist?: () => void onPipeline?: () => void
onReject?: () => void onInquire?: () => void
onReview?: () => void
} }
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) { export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort( const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2) (a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
) )
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
<Paper sx={{ p: 2.5, mb: 2 }}> <Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography> <Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
{engineActions.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
{engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant={PRIORITY_COLOR[action.priority]}
sx={
action.priority === 'HIGH'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
: { justifyContent: 'flex-start' }
}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
</Box>
)}
{/* Standard actions */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{onShortlist && ( {onInquire && (
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}> <Button
Zur Shortlist hinzufügen fullWidth
size="small"
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
)}
{engineActions.length > 0 && engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant="outlined"
sx={{ justifyContent: 'flex-start' }}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
{onPipeline && (
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
Zur Pipeline hinzufügen
</Button> </Button>
)} )}
{onCompare && ( {onCompare && (
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
Zum Vergleich hinzufügen Zum Vergleich hinzufügen
</Button> </Button>
)} )}
{onReview && (
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
Zur Überprüfung senden
</Button>
)}
{onReject && (
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
Match ablehnen
</Button>
)}
</Box> </Box>
</Paper> </Paper>
) )
@@ -16,6 +16,7 @@ import {
Train, Train,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { import {
ASSET_LABELS, ASSET_LABELS,
@@ -26,6 +27,7 @@ import {
SOURCE_LABELS, SOURCE_LABELS,
UnitRow, UnitRow,
} from './MatchDetailPropertyDetails' } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps { interface PropertyDetailPublicSectionsProps {
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
</Paper> </Paper>
)} )}
{/* ── Grundriss ── */}
{(() => {
const allUnits = property.units ?? []
const plans: Array<{ url: string; label?: string }> = []
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} mb={2} />
})()}
{/* ── Beschreibung ── */} {/* ── Beschreibung ── */}
{property.description && ( {property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
@@ -270,7 +284,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/> />
)} )}
{property.sourceUrl && ( {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}> <Box sx={{ mt: 1.25 }}>
<Button <Button
size="small" size="small"
@@ -0,0 +1,33 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
floorPlanUrl: string
onFloorPlanUrlChange: (v: string) => void
}
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
URL zu einem Grundrissbild hilft Interessenten, sich die Fläche vorzustellen.
</Typography>
<TextField
label="Grundriss-URL"
value={floorPlanUrl}
onChange={e => onFloorPlanUrlChange(e.target.value)}
size="small"
fullWidth
placeholder="https://…"
/>
{floorPlanUrl && (
<Box
component="img"
src={floorPlanUrl}
alt="Grundriss Vorschau"
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
/>
)}
</Card>
)
}
+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>
) )
} }
+9 -1
View File
@@ -11,6 +11,7 @@ export function DroppableColumn({
selectedId, selectedId,
onSelect, onSelect,
onChatClick, onChatClick,
onCardChatClick,
isOver, isOver,
}: { }: {
stage: { key: PipelineStage; label: string; color: string; bgColor: string } stage: { key: PipelineStage; label: string; color: string; bgColor: string }
@@ -18,6 +19,7 @@ export function DroppableColumn({
selectedId: string | null selectedId: string | null
onSelect: (item: PipelineItem) => void onSelect: (item: PipelineItem) => void
onChatClick: (inquiryId: string) => void onChatClick: (inquiryId: string) => void
onCardChatClick?: (item: PipelineItem) => void
isOver: boolean isOver: boolean
}) { }) {
const { setNodeRef } = useDroppable({ id: stage.key }) const { setNodeRef } = useDroppable({ id: stage.key })
@@ -43,7 +45,13 @@ export function DroppableColumn({
item={item} item={item}
isSelected={item.id === selectedId} isSelected={item.id === selectedId}
onSelect={onSelect} onSelect={onSelect}
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined} onChatClick={
item.inquiryId
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
: onCardChatClick
? (e) => { e.stopPropagation(); onCardChatClick(item) }
: undefined
}
/> />
))} ))}
{items.length === 0 && ( {items.length === 0 && (
+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
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
matchId: item.resultId, matchId: item.resultId,
needId: '', needId: '',
matchScore: item.matchScore, matchScore: item.matchScore,
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK', resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
property: { id: item.propertyId ?? item.resultId, title: item.title } as any, property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any, match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
} }
@@ -11,7 +11,6 @@ import type { ShortlistItem } from '../../domain/shortlist'
const RESULT_TYPE_LABEL: Record<string, string> = { const RESULT_TYPE_LABEL: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio', VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Direkt',
MAISON_WORK: 'Maison Work', MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Signal', FUTURE_AVAILABILITY: 'Signal',
} }
+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
View File
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
// ── Result Types ────────────────────────────────────────────────────────────── // ── Result Types ──────────────────────────────────────────────────────────────
export const ResultType = { export const ResultType = {
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO', VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
MAISON_WORK: 'MAISON_WORK', MAISON_WORK: 'MAISON_WORK',
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY', FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
} as const } as const
+3
View File
@@ -28,6 +28,9 @@ export interface Inquiry {
tenantName: string tenantName: string
tenantCompany?: string tenantCompany?: string
tenantEmail?: string tenantEmail?: string
propertyManagerName?: string
propertyManagerCompany?: string
propertyAddress?: string
subject: string subject: string
message: string message: string
status?: InquiryStatus status?: InquiryStatus
+2 -2
View File
@@ -1,11 +1,11 @@
export type PipelineStage = 'SAVED' | 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST' export type PipelineStage = 'SAVED' | 'CONTACTED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
export interface PipelineItem { export interface PipelineItem {
id: string id: string
title: string title: string
location: string location: string
matchScore: number matchScore: number
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY' resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
stage: PipelineStage stage: PipelineStage
// Source match reference — used for navigating back to the detail page // Source match reference — used for navigating back to the detail page
matchId?: string matchId?: string
+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
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
export const CONFIDENCE_MODIFIER = { export const CONFIDENCE_MODIFIER = {
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80 VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80 VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market) MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above) LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
+1 -9
View File
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
} }
export interface VerifiedPortfolioResult extends UnifiedResultBase { export interface VerifiedPortfolioResult extends UnifiedResultBase {
resultType: 'VERIFIED_PORTFOLIO' resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
property: Property
match: Match
unit?: PropertyUnit // specific unit this match refers to
}
export interface ExternalMarketResult extends UnifiedResultBase {
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
property: Property property: Property
match: Match match: Match
unit?: PropertyUnit // specific unit this match refers to unit?: PropertyUnit // specific unit this match refers to
@@ -38,5 +31,4 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
export type UnifiedMatchResult = export type UnifiedMatchResult =
| VerifiedPortfolioResult | VerifiedPortfolioResult
| ExternalMarketResult
| FutureAvailabilityResult | FutureAvailabilityResult
+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
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
expect(ranked[0].propertyId).toBe('high') expect(ranked[0].propertyId).toBe('high')
}) })
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => { it('breaks ties by result type: VERIFIED_PORTFOLIO and MAISON_WORK ranked above FUTURE_AVAILABILITY', () => {
// Build one of each type but force identical score by using same property body // Build one of each type but force identical score by using same property body
const prop = makeProperty() const prop = makeProperty()
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO }) const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 }) const future = buildFullMatch(need, { ...prop, id: 'f', resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.85 })
// If scores differ, force them equal for a clean tiebreak test // Force same score for a clean tiebreak test
if (verified.matchScore !== external.matchScore) { verified.matchScore = 75
const lower = Math.min(verified.matchScore, external.matchScore) future.matchScore = 75
verified.matchScore = lower
external.matchScore = lower
}
const ranked = rankMatches([external, verified]) const ranked = rankMatches([future, verified])
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO) expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
}) })
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
expect(calcConfidenceModifier(p)).toBe(0) expect(calcConfidenceModifier(p)).toBe(0)
}) })
it('returns -3 for external market results', () => {
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
expect(calcConfidenceModifier(p)).toBe(-3)
})
it('returns -2 for Maison Work results', () => { it('returns -2 for Maison Work results', () => {
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 }) const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
expect(calcConfidenceModifier(p)).toBe(-2) expect(calcConfidenceModifier(p)).toBe(-2)
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
}) })
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => { it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13 // MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 }) const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
expect(calcConfidenceModifier(p)).toBe(-13) expect(calcConfidenceModifier(p)).toBe(-12)
}) })
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => { it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
expect(output.finalScore).toBeGreaterThanOrEqual(85) expect(output.finalScore).toBeGreaterThanOrEqual(85)
}) })
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => { it('scores a weak match (wrong city, over budget, poor DQ, low confidence) below 50', () => {
const need = makeNeed({ preferredLocations: ['Zürich'] }) const need = makeNeed({ preferredLocations: ['Zürich'] })
const prop = makeProperty({ const prop = makeProperty({
location: { city: 'Basel', country: 'CH' }, location: { city: 'Basel', country: 'CH' },
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
dataQuality: { dataQuality: {
score: 0.30, // CRITICAL → -15 score: 0.30, // CRITICAL → -15
@@ -132,7 +132,7 @@ describe('softFactorEnrichmentService — score range invariant (all keys, 01
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => { describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
it('returns identical estimate for same location regardless of resultType', () => { it('returns identical estimate for same location regardless of resultType', () => {
// The enrichment service is location-only — resultType is irrelevant. // The enrichment service is location-only — resultType is irrelevant.
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score. // FUTURE_AVAILABILITY (pre-market) and VERIFIED_PORTFOLIO should produce the same soft factor score.
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' } const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' } const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
@@ -141,16 +141,16 @@ describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
address: sharedAddress, address: sharedAddress,
resultType: ResultType.FUTURE_AVAILABILITY, resultType: ResultType.FUTURE_AVAILABILITY,
}) })
const marketResult = makeProperty({ const portfolioResult = makeProperty({
location: sharedLocation, location: sharedLocation,
address: sharedAddress, address: sharedAddress,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
}) })
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal) const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult) const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
expect(futureEst?.score).toBe(marketEst?.score) expect(futureEst?.score).toBe(portfolioEst?.score)
expect(futureEst?.label).toBe(marketEst?.label) expect(futureEst?.label).toBe(portfolioEst?.label)
}) })
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => { it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
+4 -4
View File
@@ -191,10 +191,10 @@ export function rankMatches(matches: Match[]): Match[] {
return [...matches].sort((a, b) => { return [...matches].sort((a, b) => {
// Primary: matchScore descending // Primary: matchScore descending
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE // Secondary: VERIFIED / MAISON_WORK > FUTURE
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 } const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1 const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1 const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
if (aOrder !== bOrder) return aOrder - bOrder if (aOrder !== bOrder) return aOrder - bOrder
// Tertiary: higher confidence first // Tertiary: higher confidence first
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0) return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
+4 -15
View File
@@ -499,8 +499,6 @@ export function calcConfidenceModifier(property: Property): number {
let mod = 0 let mod = 0
if (property.resultType === ResultType.FUTURE_AVAILABILITY) { if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
} else if (property.resultType === ResultType.MAISON_WORK) { } else if (property.resultType === ResultType.MAISON_WORK) {
mod += CONFIDENCE_MODIFIER.MAISON_WORK mod += CONFIDENCE_MODIFIER.MAISON_WORK
} else { } else {
@@ -586,17 +584,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
] ]
const mustHaveEval = scoreMustHaves(allMustHaveText, property) const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Modifiers: data quality and confidence adjust the final score ───────── // ── Final score: hard/soft weighted sum, clamped 0100 ───────────────────
// These are intentionally applied after the hard/soft weighted sum so that const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
// a low-confidence or poor-data property can be penalised without distorting
// the individual criterion breakdown.
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
+ dataQualityModifier + confidenceModifier
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal))) const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ── // ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -624,8 +613,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore, finalScore,
hardMatchScore, hardMatchScore,
softFactorScore, softFactorScore,
dataQualityModifier, dataQualityModifier: 0,
confidenceModifier, confidenceModifier: 0,
positiveFactors, positiveFactors,
negativeFactors, negativeFactors,
allHardFactors: hardFactors, allHardFactors: hardFactors,
+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,
} }
+3 -17
View File
@@ -6,17 +6,16 @@ import { useSchattenmarktSignals } from './useSchattenmarktSignals'
import type { import type {
UnifiedMatchResult, UnifiedMatchResult,
VerifiedPortfolioResult, VerifiedPortfolioResult,
ExternalMarketResult,
FutureAvailabilityResult, FutureAvailabilityResult,
} from '../domain/unifiedResult' } from '../domain/unifiedResult'
export function useUnifiedResults(needId?: string) { export function useUnifiedResults(needId?: string) {
const allMatchesQuery = useMatches()
const needMatchesQuery = useMatchesByNeed(needId ?? '') const needMatchesQuery = useMatchesByNeed(needId ?? '')
const propertiesQuery = useProperties() const propertiesQuery = useProperties()
const signalsQuery = useFutureSignals() const signalsQuery = useFutureSignals()
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery // Always scope to a specific need — showing cross-need results causes duplicates per property
const matchesQuery = needMatchesQuery
const isLoading = const isLoading =
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
@@ -64,24 +63,11 @@ export function useUnifiedResults(needId?: string) {
? (property.units?.find(u => u.id === match.unitId) ?? undefined) ? (property.units?.find(u => u.id === match.unitId) ?? undefined)
: undefined : undefined
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
const result: ExternalMarketResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
property,
match,
unit: matchUnit,
}
return [result]
}
const result: VerifiedPortfolioResult = { const result: VerifiedPortfolioResult = {
matchId: match.id, matchId: match.id,
needId: match.needId, needId: match.needId,
matchScore: match.matchScore, matchScore: match.matchScore,
resultType: 'VERIFIED_PORTFOLIO', resultType: rt as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
property, property,
match, match,
unit: matchUnit, unit: matchUnit,
+1 -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',
} }
@@ -69,7 +70,6 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
// Result type display labels // Result type display labels
export const RESULT_TYPE_LABELS: Record<string, string> = { export const RESULT_TYPE_LABELS: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Plattform', VERIFIED_PORTFOLIO: 'Plattform',
EXTERNAL_MARKET: 'Plattform',
MAISON_WORK: 'Maison Work', MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Zukunftssignal', FUTURE_AVAILABILITY: 'Zukunftssignal',
} }
-2
View File
@@ -7,7 +7,6 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
export const DS_COLORS = { export const DS_COLORS = {
resultType: { resultType: {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' }, VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
EXTERNAL_MARKET: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' }, MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' }, FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
}, },
@@ -199,7 +198,6 @@ export function scoreToDataQualityLevel(score: number): DataQualityLevel {
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = { export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' }, VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
EXTERNAL_MARKET: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
} }
-1
View File
@@ -99,7 +99,6 @@ export function canViewProperty(
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) { if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
return property.organizationId === user.organizationId return property.organizationId === user.organizationId
} }
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.MAISON_WORK) return true if (property.resultType === ResultType.MAISON_WORK) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) { if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW) return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
+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 }
}
+241
View File
@@ -0,0 +1,241 @@
import type { Inquiry } from '../domain/inquiry'
export const mockDemandInquiries: Inquiry[] = [
// 1 — NEW (unanswered, unread from manager)
{
id: 'dinq-001',
organizationId: 'org-wincasa',
propertyId: 'prop-technopark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyManagerName: 'Martin Wegmann',
propertyManagerCompany: 'Technopark Zürich AG',
propertyAddress: 'Bürofläche Technoparkstrasse 1',
subject: 'Anfrage: Bürofläche Technoparkstrasse 1',
message:
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
status: 'new',
unreadCount: 0,
isRead: true,
matchScore: 94,
createdAt: '2026-05-22T09:15:00Z',
updatedAt: '2026-05-22T09:15:00Z',
thread: [
{
id: 'dmsg-001-1',
inquiryId: 'dinq-001',
senderType: 'tenant',
senderName: 'Admin User',
body:
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
attachments: [],
createdAt: '2026-05-22T09:15:00Z',
},
],
},
// 2 — NEW (unanswered, unread badge)
{
id: 'dinq-002',
organizationId: 'org-wincasa',
propertyId: 'prop-hardturmpark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyManagerName: 'Claudia Stöckli',
propertyManagerCompany: 'PSP Swiss Property AG',
propertyAddress: 'Bürofläche Hardturmstrasse 161',
subject: 'Anfrage: Bürofläche Hardturmstrasse 161',
message:
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
status: 'new',
unreadCount: 1,
isRead: false,
matchScore: 88,
createdAt: '2026-05-23T14:30:00Z',
updatedAt: '2026-05-23T14:30:00Z',
thread: [
{
id: 'dmsg-002-1',
inquiryId: 'dinq-002',
senderType: 'tenant',
senderName: 'Admin User',
body:
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
attachments: [],
createdAt: '2026-05-23T14:30:00Z',
},
],
},
// 3 — IN_PROGRESS (ongoing conversation)
{
id: 'dinq-003',
organizationId: 'org-wincasa',
propertyId: 'prop-sihlcity',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyManagerName: 'Thomas Brunner',
propertyManagerCompany: 'Wincasa AG',
propertyAddress: 'Bürofläche Kalanderplatz 1, Sihlcity',
subject: 'Anfrage: Bürofläche Kalanderplatz 1, Sihlcity',
message:
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
status: 'in_progress',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-05-21T17:00:00Z',
matchScore: 91,
createdAt: '2026-05-19T10:00:00Z',
updatedAt: '2026-05-21T16:45:00Z',
thread: [
{
id: 'dmsg-003-1',
inquiryId: 'dinq-003',
senderType: 'tenant',
senderName: 'Admin User',
body:
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
attachments: [],
createdAt: '2026-05-19T10:00:00Z',
},
{
id: 'dmsg-003-2',
inquiryId: 'dinq-003',
senderType: 'supply_user',
senderName: 'Thomas Brunner',
body:
'Guten Tag\n\nVielen Dank für Ihr Interesse. Anbei das Exposé mit Grundriss und Konditionsübersicht. Die Fläche steht ab 1. August zur Verfügung. Haben Sie Interesse an einer Besichtigung diese Woche?\n\nFreundliche Grüsse\nThomas Brunner\nWincasa AG',
attachments: [
{ id: 'datt-003-1', fileName: 'Expose_Sihlcity_Kalanderplatz.pdf', fileType: 'application/pdf', fileSize: 1540000 },
],
createdAt: '2026-05-20T09:30:00Z',
},
{
id: 'dmsg-003-3',
inquiryId: 'dinq-003',
senderType: 'tenant',
senderName: 'Admin User',
body:
'Herzlichen Dank für die schnelle Antwort. Ja, eine Besichtigung würde uns gut passen. Wie sieht es am Donnerstag um 10:00 Uhr aus?\n\nFreundliche Grüsse\nAdmin User',
attachments: [],
createdAt: '2026-05-21T16:45:00Z',
},
],
},
// 4 — IN_PROGRESS (ongoing, verwalter replied)
{
id: 'dinq-004',
organizationId: 'org-wincasa',
propertyId: 'prop-bahnhofzug',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyManagerName: 'Sandra Huber',
propertyManagerCompany: 'Swiss Prime Site AG',
propertyAddress: 'Bürofläche Bahnhofstrasse 28, Zug',
subject: 'Anfrage: Bürofläche Bahnhofstrasse 28, Zug',
message:
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
status: 'in_progress',
unreadCount: 2,
isRead: false,
matchScore: 86,
createdAt: '2026-05-16T08:45:00Z',
updatedAt: '2026-05-20T11:00:00Z',
thread: [
{
id: 'dmsg-004-1',
inquiryId: 'dinq-004',
senderType: 'tenant',
senderName: 'Admin User',
body:
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
attachments: [],
createdAt: '2026-05-16T08:45:00Z',
},
{
id: 'dmsg-004-2',
inquiryId: 'dinq-004',
senderType: 'supply_user',
senderName: 'Sandra Huber',
body:
'Guten Tag\n\nVielen Dank für Ihre Nachricht. Die Fläche von ca. 520 m² ist ab Oktober verfügbar. Der Nettomietzins beträgt CHF 290/m² p.a. Nebenkosten werden nach Aufwand abgerechnet. Darf ich Ihnen ein Besichtigungsfenster vorschlagen?\n\nFreundliche Grüsse\nSandra Huber\nSwiss Prime Site AG',
attachments: [
{ id: 'datt-004-1', fileName: 'Konditionsblatt_Bahnhofstr28.pdf', fileType: 'application/pdf', fileSize: 820000 },
],
createdAt: '2026-05-18T14:20:00Z',
},
{
id: 'dmsg-004-3',
inquiryId: 'dinq-004',
senderType: 'supply_user',
senderName: 'Sandra Huber',
body:
'Guten Tag\n\nNur eine kurze Nachfrage — haben Sie die Unterlagen erhalten? Gerne würden wir einen Termin festlegen.\n\nFreundliche Grüsse\nSandra Huber',
attachments: [],
createdAt: '2026-05-20T11:00:00Z',
},
],
},
// 5 — ANSWERED (completed exchange)
{
id: 'dinq-005',
organizationId: 'org-wincasa',
propertyId: 'prop-dreispitz',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyManagerName: 'Reto Mäder',
propertyManagerCompany: 'Allreal AG',
propertyAddress: 'Logistikfläche Dreispitz Basel',
subject: 'Anfrage: Logistikfläche Dreispitz Basel',
message:
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
status: 'answered',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-05-14T10:00:00Z',
matchScore: 79,
createdAt: '2026-05-10T11:00:00Z',
updatedAt: '2026-05-14T09:45:00Z',
thread: [
{
id: 'dmsg-005-1',
inquiryId: 'dinq-005',
senderType: 'tenant',
senderName: 'Admin User',
body:
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
attachments: [],
createdAt: '2026-05-10T11:00:00Z',
},
{
id: 'dmsg-005-2',
inquiryId: 'dinq-005',
senderType: 'supply_user',
senderName: 'Reto Mäder',
body:
'Guten Tag\n\nJa, die Fläche ist noch verfügbar. Wir haben ca. 1\'800 m² mit 5.5 m Hallenhöhe und zwei Rampen. Besichtigung möglich ab nächster Woche. Anbei das Datenblatt.\n\nFreundliche Grüsse\nReto Mäder\nAllreal AG',
attachments: [
{ id: 'datt-005-1', fileName: 'Datenblatt_Dreispitz_Logistik.pdf', fileType: 'application/pdf', fileSize: 1100000 },
],
createdAt: '2026-05-12T08:30:00Z',
},
{
id: 'dmsg-005-3',
inquiryId: 'dinq-005',
senderType: 'tenant',
senderName: 'Admin User',
body:
'Herzlichen Dank. Die Unterlagen sehen vielversprechend aus. Wir melden uns bis Ende Woche mit einem Besichtigungstermin.\n\nFreundliche Grüsse\nAdmin User',
attachments: [],
createdAt: '2026-05-14T09:45:00Z',
},
],
},
]
+3 -3
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',
@@ -121,7 +121,7 @@ export const mockPipelineItems: PipelineItem[] = [
title: 'Gewerbe Güterstrasse Basel', title: 'Gewerbe Güterstrasse Basel',
location: 'Basel', location: 'Basel',
matchScore: 68, matchScore: 68,
resultType: 'EXTERNAL_MARKET', resultType: 'VERIFIED_PORTFOLIO',
stage: 'CLOSED_LOST', stage: 'CLOSED_LOST',
areaLabel: '800 m²', areaLabel: '800 m²',
rentLabel: 'CHF 28/m²', rentLabel: 'CHF 28/m²',
+44 -23
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[] = [
@@ -1071,14 +1072,14 @@ export const mockProperties: Property[] = [
}, },
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// EXTERNAL_MARKET (10) // VERIFIED_PORTFOLIO — formerly external market (migrated)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
{ {
id: 'prop-003', id: 'prop-003',
title: 'Retail-Fläche Bahnhofstrasse 88', title: 'Retail-Fläche Bahnhofstrasse 88',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } }, location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' }, address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 320, areaSqm: 320,
@@ -1122,7 +1123,7 @@ export const mockProperties: Property[] = [
id: 'prop-004', id: 'prop-004',
title: 'Gemischte Gewerbeeinheit Europaallee', title: 'Gemischte Gewerbeeinheit Europaallee',
assetType: AssetType.MIXED, assetType: AssetType.MIXED,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } }, location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' }, address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
areaSqm: 1150, areaSqm: 1150,
@@ -1246,7 +1247,7 @@ export const mockProperties: Property[] = [
id: 'prop-017', id: 'prop-017',
title: 'Ladenfläche Löwenstrasse 28', title: 'Ladenfläche Löwenstrasse 28',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } }, location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' }, address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 350, areaSqm: 350,
@@ -1290,7 +1291,7 @@ export const mockProperties: Property[] = [
id: 'prop-018', id: 'prop-018',
title: 'Bürofläche Breitenrain 14', title: 'Bürofläche Breitenrain 14',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } }, location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' }, address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
areaSqm: 780, areaSqm: 780,
@@ -1458,7 +1459,7 @@ export const mockProperties: Property[] = [
id: 'prop-022', id: 'prop-022',
title: 'Bürofläche St.Gallen Centrum 7', title: 'Bürofläche St.Gallen Centrum 7',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } }, location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' }, address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
areaSqm: 700, areaSqm: 700,
@@ -1666,7 +1667,7 @@ export const mockProperties: Property[] = [
id: 'prop-031', id: 'prop-031',
title: 'Bürofläche Hardturm West 16', title: 'Bürofläche Hardturm West 16',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } }, location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' }, address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 780, areaSqm: 780,
@@ -1780,7 +1781,7 @@ export const mockProperties: Property[] = [
id: 'prop-034', id: 'prop-034',
title: 'Ladenfläche Lorrainestrasse 8', title: 'Ladenfläche Lorrainestrasse 8',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } }, location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' }, address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
areaSqm: 340, areaSqm: 340,
@@ -1844,7 +1845,7 @@ export const mockProperties: Property[] = [
id: 'prop-036', id: 'prop-036',
title: 'Lagerhalle Klybeckstrasse 280', title: 'Lagerhalle Klybeckstrasse 280',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } }, location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' }, address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2600, areaSqm: 2600,
@@ -2206,7 +2207,7 @@ export const mockProperties: Property[] = [
id: 'prop-055', id: 'prop-055',
title: 'Ladenfläche Weststrasse 44', title: 'Ladenfläche Weststrasse 44',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } }, location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' }, address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
areaSqm: 150, areaSqm: 150,
@@ -2254,7 +2255,7 @@ export const mockProperties: Property[] = [
id: 'prop-056', id: 'prop-056',
title: 'Gewerbefläche Hohlstrasse 88', title: 'Gewerbefläche Hohlstrasse 88',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } }, location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' }, address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
areaSqm: 155, areaSqm: 155,
@@ -2667,7 +2668,7 @@ export const mockProperties: Property[] = [
id: 'prop-064', id: 'prop-064',
title: 'Bürofläche Förrlibuckstrasse 22', title: 'Bürofläche Förrlibuckstrasse 22',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } }, location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' }, address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 185, areaSqm: 185,
@@ -2714,7 +2715,7 @@ export const mockProperties: Property[] = [
id: 'prop-065', id: 'prop-065',
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon', title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } }, location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' }, address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
areaSqm: 175, areaSqm: 175,
@@ -2813,7 +2814,7 @@ export const mockProperties: Property[] = [
id: 'prop-067', id: 'prop-067',
title: 'Bürofläche Altstetterstrasse 222', title: 'Bürofläche Altstetterstrasse 222',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } }, location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' }, address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 190, areaSqm: 190,
@@ -3175,7 +3176,7 @@ export const mockProperties: Property[] = [
id: 'prop-074', id: 'prop-074',
title: 'Bürofläche Uraniastrasse 14', title: 'Bürofläche Uraniastrasse 14',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } }, location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' }, address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 510, areaSqm: 510,
@@ -3223,7 +3224,7 @@ export const mockProperties: Property[] = [
id: 'prop-075', id: 'prop-075',
title: 'Bürofläche Seefeldstrasse 30', title: 'Bürofläche Seefeldstrasse 30',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } }, location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' }, address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
areaSqm: 540, areaSqm: 540,
@@ -3483,7 +3484,7 @@ export const mockProperties: Property[] = [
id: 'prop-080', id: 'prop-080',
title: 'Ladenfläche Spitalgasse 34', title: 'Ladenfläche Spitalgasse 34',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } }, location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' }, address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 280, areaSqm: 280,
@@ -3531,7 +3532,7 @@ export const mockProperties: Property[] = [
id: 'prop-081', id: 'prop-081',
title: 'Ladenfläche Münstergasse 12', title: 'Ladenfläche Münstergasse 12',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } }, location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' }, address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 350, areaSqm: 350,
@@ -3838,7 +3839,7 @@ export const mockProperties: Property[] = [
id: 'prop-087', id: 'prop-087',
title: 'Lagerhalle Hauptstrasse 180, Pratteln', title: 'Lagerhalle Hauptstrasse 180, Pratteln',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } }, location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' }, address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
areaSqm: 3000, areaSqm: 3000,
@@ -3885,7 +3886,7 @@ export const mockProperties: Property[] = [
id: 'prop-088', id: 'prop-088',
title: 'Lagerhalle Westquaistrasse 12', title: 'Lagerhalle Westquaistrasse 12',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } }, location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' }, address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2600, areaSqm: 2600,
@@ -4091,7 +4092,7 @@ export const mockProperties: Property[] = [
id: 'prop-092', id: 'prop-092',
title: 'Bürofläche Hohlstrasse 534, Altstetten', title: 'Bürofläche Hohlstrasse 534, Altstetten',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } }, location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' }, address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 900, areaSqm: 900,
@@ -4138,7 +4139,7 @@ export const mockProperties: Property[] = [
id: 'prop-093', id: 'prop-093',
title: 'Bürofläche Technopark Zürich-West', title: 'Bürofläche Technopark Zürich-West',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } }, location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' }, address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 960, areaSqm: 960,
@@ -4343,7 +4344,7 @@ export const mockProperties: Property[] = [
id: 'prop-097', id: 'prop-097',
title: 'Bürofläche Baarerstrasse 18, Zug', title: 'Bürofläche Baarerstrasse 18, Zug',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } }, location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' }, address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 320, areaSqm: 320,
@@ -4487,3 +4488,23 @@ export const mockProperties: Property[] = [
}, },
] ]
// Assign images sequentially per pool so no two properties ever share the same image
// (until pool wraps, which happens after ~12 properties per pool type)
const _poolCounters: Record<string, number> = {}
mockProperties.forEach(p => {
const district = (p.location.district ?? '').toLowerCase()
const prestige = p.softFactors?.prestige ?? 60
const key = getPoolKey(p.assetType, district, prestige)
const poolIndex = _poolCounters[key] ?? 0
_poolCounters[key] = poolIndex + 1
p.images = [resolvePropertyImage({
assetType: p.assetType,
city: p.location.city,
district: p.location.district,
prestige: p.softFactors?.prestige,
floorLevel: p.floorLevel,
propertyId: p.id,
poolIndex,
})]
})
+25 -11
View File
@@ -5,7 +5,8 @@ import {
InputAdornment, Alert, InputAdornment, Alert,
} from '@mui/material' } from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react' import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockInquiries } from '../../mock-data/inquiries' import { mockDemandInquiries } from '../../mock-data/demandInquiries'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import type { InquiryMessage } from '../../domain/inquiry' import type { InquiryMessage } from '../../domain/inquiry'
@@ -13,6 +14,7 @@ import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble' import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem' import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds' import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
import { useSessionStore } from '../../stores/sessionStore'
// ── Config ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
@@ -34,9 +36,13 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry') const preselectedId = searchParams.get('inquiry')
const [inquiries, setInquiries] = useState(mockInquiries) const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries)
const [inquiries, setInquiries] = useState(mockDemandInquiries)
const allInquiries = [...storeInquiries, ...inquiries]
const [selectedId, setSelectedId] = useState<string | null>( const [selectedId, setSelectedId] = useState<string | null>(
preselectedId ?? mockInquiries[0]?.id ?? null preselectedId ?? mockDemandInquiries[0]?.id ?? null
) )
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
@@ -45,18 +51,21 @@ export default function Anfragen() {
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null) const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
const threadRef = useRef<HTMLDivElement>(null) const threadRef = useRef<HTMLDivElement>(null)
const filtered = inquiries.filter(inq => { const filtered = allInquiries.filter(inq => {
const q = search.toLowerCase() const q = search.toLowerCase()
const matchesSearch = !q || const matchesSearch = !q ||
inq.tenantName.toLowerCase().includes(q) || inq.tenantName.toLowerCase().includes(q) ||
inq.subject.toLowerCase().includes(q) || inq.subject.toLowerCase().includes(q) ||
(inq.tenantCompany?.toLowerCase().includes(q) ?? false) (inq.tenantCompany?.toLowerCase().includes(q) ?? false) ||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
return matchesSearch && matchesStatus return matchesSearch && matchesStatus
}) })
const selected = inquiries.find(i => i.id === selectedId) ?? null const selected = allInquiries.find(i => i.id === selectedId) ?? null
const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0) const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
// Pipeline link for currently selected inquiry // Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId const linkedPipelineItem = selected?.propertyId
@@ -176,6 +185,7 @@ export default function Anfragen() {
inq={inq} inq={inq}
isSelected={inq.id === selectedId} isSelected={inq.id === selectedId}
hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))} hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
perspective="demand"
onSelect={handleSelect} onSelect={handleSelect}
/> />
))} ))}
@@ -205,12 +215,16 @@ export default function Anfragen() {
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</IconButton> </IconButton>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}> <Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}>
{selected.tenantName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} {(selected.propertyAddress ?? selected.subject).split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
</Avatar> </Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.tenantName}</Typography> <Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.propertyAddress ?? selected.subject}</Typography>
{selected.tenantCompany && <Typography variant="caption" color="text.secondary">{selected.tenantCompany}</Typography>} {(selected.propertyManagerCompany ?? selected.propertyManagerName) && (
<Typography variant="caption" color="text.secondary">
{selected.propertyManagerCompany ?? selected.propertyManagerName ?? ''}
</Typography>
)}
</Box> </Box>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
{selected.subject} {selected.subject}
@@ -281,7 +295,7 @@ export default function Anfragen() {
{/* Thread */} {/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => ( {selected.thread.map(msg => (
<AnfragenMessageBubble key={msg.id} msg={msg} /> <AnfragenMessageBubble key={msg.id} msg={msg} currentUserName={currentUser?.name ?? undefined} />
))} ))}
</Box> </Box>
+14 -5
View File
@@ -1,9 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material' import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react' import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
import { useNavigate, useParams } from 'react-router' import { useNavigate, useParams } from 'react-router'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { PropertyMap } from '../../components/shared' import { PropertyMap } from '../../components/shared'
@@ -46,6 +48,7 @@ export default function MatchDetail() {
const navigate = useNavigate() const navigate = useNavigate()
const { addToCompare } = useCompareStore() const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore() const { openSavedDialog } = usePipelineStore()
const { openInquiryDialog } = useInquiryStore()
const [showFullAnalysis, setShowFullAnalysis] = useState(false) const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '') const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
@@ -88,7 +91,7 @@ export default function MatchDetail() {
const handleCompare = () => { const handleCompare = () => {
if (match && !isFuture && property) { if (match && !isFuture && property) {
addToCompare({ addToCompare({
resultType: property.resultType === 'EXTERNAL_MARKET' ? 'EXTERNAL_MARKET' : 'VERIFIED_PORTFOLIO', resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property, matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
}) })
} else if (match && isFuture && signal) { } else if (match && isFuture && signal) {
@@ -126,6 +129,7 @@ export default function MatchDetail() {
return ( return (
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}> <Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
<AddToPipelineDialog /> <AddToPipelineDialog />
<InquiryQuickDialog />
{/* Sticky back nav */} {/* Sticky back nav */}
<Box sx={{ <Box sx={{
@@ -164,7 +168,7 @@ export default function MatchDetail() {
/> />
{/* ── Main content ── */} {/* ── Main content ── */}
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Section A: Warum dieser Match? ── */} {/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
@@ -196,9 +200,14 @@ export default function MatchDetail() {
<NextActionsPanel <NextActionsPanel
match={match} match={match}
onCompare={handleCompare} onCompare={handleCompare}
onShortlist={handleShortlist} onPipeline={handleShortlist}
onReview={() => {}} onInquire={() => openInquiryDialog({
onReject={() => {}} propertyTitle: title,
location,
areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : undefined,
rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
matchScore: match.matchScore,
})}
/> />
{/* ── Full analysis toggle ── */} {/* ── Full analysis toggle ── */}
+10
View File
@@ -9,7 +9,9 @@ import {
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { TrendingUp } from 'lucide-react' import { TrendingUp } from 'lucide-react'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline' import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
import { STAGES } from '../../components/pipeline/pipelineConstants' import { STAGES } from '../../components/pipeline/pipelineConstants'
import { DraggableCard } from '../../components/pipeline/PipelineCard' import { DraggableCard } from '../../components/pipeline/PipelineCard'
@@ -22,6 +24,7 @@ export default function Pipeline() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: items = [] } = usePipelineItems() const { data: items = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage() const { mutate: moveStage } = useMoveStage()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null) const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [overId, setOverId] = useState<string | null>(null) const [overId, setOverId] = useState<string | null>(null)
@@ -66,6 +69,7 @@ export default function Pipeline() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog /> <AddToPipelineDialog />
<InquiryQuickDialog />
{/* Header */} {/* Header */}
<Box sx={{ <Box sx={{
@@ -123,6 +127,12 @@ export default function Pipeline() {
selectedId={syncedSelected?.id ?? null} selectedId={syncedSelected?.id ?? null}
onSelect={handleSelect} onSelect={handleSelect}
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)} onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
onCardChatClick={(item) => openInquiryDialog({
propertyTitle: item.title,
location: item.location ?? '',
matchScore: item.matchScore ?? 0,
propertyId: item.propertyId,
})}
isOver={isOver} isOver={isOver}
/> />
</Box> </Box>
+5 -3
View File
@@ -14,6 +14,7 @@ import {
UnifiedResultFeed, UnifiedResultFeed,
} from '../../components/results' } from '../../components/results'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { ErrorState } from '../../components/ui' import { ErrorState } from '../../components/ui'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -65,7 +66,8 @@ export default function Results() {
} }
}, [activeNeedIdFromNav, queryClient]) }, [activeNeedIdFromNav, queryClient])
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId) const needsLoading = allNeeds.length === 0 && !activeNeedIdFromNav
const { data: results = [], isLoading, error } = useUnifiedResults(needsLoading ? undefined : effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
@@ -73,7 +75,7 @@ export default function Results() {
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => { const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0, missing = 0 let platform = 0, maison = 0, future = 0, missing = 0
for (const r of results) { for (const r of results) {
if (r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET') platform++ if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
else if (r.resultType === 'MAISON_WORK') maison++ else if (r.resultType === 'MAISON_WORK') maison++
else if (r.resultType === 'FUTURE_AVAILABILITY') future++ else if (r.resultType === 'FUTURE_AVAILABILITY') future++
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) && if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
@@ -91,7 +93,6 @@ export default function Results() {
return filterSource === 'ALL' || filterSource === 'PLATFORM' return filterSource === 'ALL' || filterSource === 'PLATFORM'
} }
if (filterSource === 'ALL') return true if (filterSource === 'ALL') return true
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
return r.resultType === filterSource return r.resultType === filterSource
}) })
return sortResults(filtered, sortBy) return sortResults(filtered, sortBy)
@@ -102,6 +103,7 @@ export default function Results() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog /> <AddToPipelineDialog />
<InquiryQuickDialog />
<ResultFeedHeader <ResultFeedHeader
total={sorted.length} total={sorted.length}
platformCount={platformCount} platformCount={platformCount}
+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',
+4 -2
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 = {
@@ -47,7 +48,7 @@ export function buildCreatePropertyInput(fields: {
return { return {
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`, title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
assetType: assetType as typeof AssetType[keyof typeof AssetType], assetType: assetType as typeof AssetType[keyof typeof AssetType],
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.VERIFIED_PORTFOLIO,
sourceType: 'DIRECT', sourceType: 'DIRECT',
location: { city, country: 'CH' }, location: { city, country: 'CH' },
address: { address: {
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
softFactors: sf, softFactors: sf,
hardFacts: hf, hardFacts: hf,
images: images.length > 0 ? images : undefined, images: images.length > 0 ? images : undefined,
floorPlanUrl: floorPlanUrl.trim() || undefined,
dataQuality: { dataQuality: {
score: 1.0, score: 1.0,
missingCriticalFields: [], missingCriticalFields: [],
+1 -1
View File
@@ -1,7 +1,7 @@
export interface DashboardStats { export interface DashboardStats {
totalProperties: number totalProperties: number
verifiedProperties: number verifiedProperties: number
externalMarketProperties: number verifiedPortfolioProperties: number
futureSignalProperties: number futureSignalProperties: number
totalMatches: number totalMatches: number
pendingReviews: number pendingReviews: number
+1 -1
View File
@@ -32,7 +32,7 @@ export const MockupDashboardProvider: IDashboardProvider = {
const stats: DashboardStats = { const stats: DashboardStats = {
totalProperties: props.length, totalProperties: props.length,
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length, verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
externalMarketProperties: props.filter(p => p.resultType === 'EXTERNAL_MARKET').length, verifiedPortfolioProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length, futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
totalMatches: matches.length, totalMatches: matches.length,
pendingReviews: queue.filter(r => r.status === 'PENDING').length, pendingReviews: queue.filter(r => r.status === 'PENDING').length,
+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]
} }
+1 -2
View File
@@ -60,8 +60,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
// Contextual Zürich inference: only truly Zürich-specific place/brand names // Contextual Zürich inference: only truly Zürich-specific place/brand names
const ZURICH_ONLY_SIGNALS = [ const ZURICH_ONLY_SIGNALS = [
'industrie groove', 'industrie-groove', 'pfingstweidstrasse', 'freilager', 'europaallee',
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd', 'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
'hürlimann', 'viadukt', 'schiffbau', 'hürlimann', 'viadukt', 'schiffbau',
] ]
+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
+72
View File
@@ -0,0 +1,72 @@
import { create } from 'zustand'
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
export interface PendingInquiry {
propertyTitle: string
location: string
areaLabel?: string
rentLabel?: string
matchScore: number
matchId?: string
propertyId?: string
pipelineItemId?: string
}
interface InquiryStore {
// Dialog state
dialogOpen: boolean
pendingInquiry: PendingInquiry | null
openInquiryDialog: (item: PendingInquiry) => void
closeInquiryDialog: () => void
// Sent inquiries (in-memory, persists for the session)
sentInquiries: Inquiry[]
addInquiry: (item: PendingInquiry, message: string) => void
}
export const useInquiryStore = create<InquiryStore>((set) => ({
dialogOpen: false,
pendingInquiry: null,
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
sentInquiries: [],
addInquiry: (item, message) => {
const now = new Date().toISOString()
const msgId = `msg-sent-${Date.now()}`
const id = `sent-${Date.now()}`
const thread: InquiryMessage = {
id: msgId,
inquiryId: id,
senderType: 'tenant',
senderName: 'Admin User',
body: message,
attachments: [],
createdAt: now,
}
const inquiry: Inquiry = {
id,
organizationId: 'org-wincasa',
propertyId: item.propertyId ?? 'unknown',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyAddress: item.propertyTitle,
propertyManagerName: 'Verwalter',
propertyManagerCompany: '',
subject: 'Anfrage: ' + item.propertyTitle,
message,
status: 'new',
unreadCount: 0,
isRead: true,
matchScore: item.matchScore,
createdAt: now,
updatedAt: now,
thread: [thread],
}
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
},
}))