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(npm install *)",
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)"
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
]
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"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 { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles,
}
-1
View File
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
}
+24 -5
View File
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
const primaryTitle = perspective === 'demand'
? (inq.propertyAddress ?? inq.subject)
: inq.tenantName
const secondaryLine = perspective === 'demand'
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
: (inq.tenantCompany ?? '')
const lastMsgPrefix = (() => {
if (!lastMsg) return ''
if (perspective === 'demand') {
return lastMsg.senderType === 'tenant'
? 'Sie: '
: `${inq.propertyManagerName ?? 'Verwalter'}: `
}
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
})()
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName}
{primaryTitle}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{inq.tenantCompany && (
{secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany}
{secondaryLine}
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
{lastMsgPrefix}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
@@ -10,7 +10,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
}
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
}
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
+1 -1
View File
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
-1
View File
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
@@ -4,6 +4,7 @@ import {
CheckCircle2,
} from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
@@ -19,12 +20,15 @@ interface Props {
lat?: number
lng?: number
cityLabel?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
}
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<Box sx={{
position: 'absolute', top: 10, left: 10,
background: theme.gradient, borderRadius: '10px',
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage
</Button>
{vm.actions.map(a => (
<Button
key={a.id}
+187 -90
View File
@@ -1,119 +1,216 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
imageUrl?: string
overlayTypeLabel?: string
}
export function MatchCardCompact({ vm }: Props) {
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const borderColor = vm.isCompareSelected
? '#7c3aed'
: vm.isSelected
? '#1e3a5f'
: 'transparent'
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
return (
<Card
sx={{
p: 2.5,
mb: 1.5,
border: `2px solid ${borderColor}`,
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'border-color 0.15s',
}}
>
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
{vm.disclaimer}
</Alert>
)}
<Box sx={{
display: 'flex',
mb: 1.5,
borderRadius: 2,
overflow: 'hidden',
border: `2px solid ${borderColor}`,
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
background: theme.cardBg,
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'box-shadow 0.15s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
}}>
{vm.isReviewRequired && (
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
Manuelle Überprüfung erforderlich
</Alert>
)}
{/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
{imageUrl ? (
<img
src={imageUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{
width: '100%', height: '100%', minHeight: 120,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
}}>
<MapPin size={24} color="#7ba3bf" />
</Box>
)}
{/* Header: score → type → confidence → availability → risk */}
<MatchCardHeader vm={vm} compact />
{/* Score badge */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
background: theme.gradient, borderRadius: '8px',
px: 1, py: 0.25,
border: `1px solid ${theme.border}`,
boxShadow: `0 2px 8px ${theme.glow}`,
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
</Box>
{/* Title + location (max 2 lines) */}
<Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{/* Asset type label */}
{overlayTypeLabel && (
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
{overlayTypeLabel}
</Typography>
</Box>
)}
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Alerts */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, 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}
</Typography>
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
{vm.locationLabel}
</Typography>
</Box>
{/* Explainability summary */}
{vm.explainabilitySummary && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{vm.explainabilitySummary}
</Typography>
)}
<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>
<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>
</Box>
)
}
})
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
}
@@ -0,0 +1,51 @@
import { Box, Paper, Typography } from '@mui/material'
import { FileImage } from 'lucide-react'
import { DS_TEXT } from '../../lib/ds'
interface FloorPlan {
url: string
label?: string
}
interface Props {
plans: FloorPlan[]
mb?: number | string
}
export function FloorPlanSection({ plans, mb }: Props) {
if (plans.length === 0) return null
return (
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FileImage size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{plans.map((p, i) => (
<Box key={i}>
{p.label && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
{p.label}
</Typography>
)}
<Box
component="img"
src={p.url}
alt={p.label ?? 'Grundriss'}
sx={{
width: '100%',
borderRadius: 1,
border: '1px solid rgba(0,0,0,0.08)',
display: 'block',
objectFit: 'contain',
maxHeight: 480,
bgcolor: '#f8fafc',
}}
/>
</Box>
))}
</Box>
</Paper>
)
}
@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Typography,
} from '@mui/material'
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
}
export function InquiryQuickDialog() {
const showToast = useToastStore(s => s.showToast)
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
useEffect(() => {
if (dialogOpen && pendingInquiry) {
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
}
}, [dialogOpen, pendingInquiry])
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
if (!pendingInquiry) return null
return (
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Send size={16} />
Anfrage senden
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{pendingInquiry.location}
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
{' · '}Match {pendingInquiry.matchScore}%
</Typography>
</Box>
<TextField
fullWidth
multiline
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
Der Text kann vor dem Absenden angepasst werden.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
<Button
onClick={handleSend}
variant="contained"
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
</DialogActions>
</Dialog>
)
}
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
@@ -1,8 +1,10 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
</Paper>
)}
{/* Grundriss */}
{(() => {
const plans: Array<{ url: string; label?: string }> = []
if (matchedUnit?.floorPlanUrl) {
const lbl = matchedUnit.unitLabel
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
: FLOOR_LABEL(matchedUnit.floorLevel)
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
} else {
units.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
}
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} />
})()}
{/* Beschreibung */}
{property.description && (
<Paper sx={{ p: 2.5 }}>
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
)}
{property.sourceUrl && (
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat
@@ -1,23 +1,17 @@
import { Box, Button, Paper, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { Match, NextBestAction } from '../../domain/match'
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
HIGH: 'contained',
MEDIUM: 'outlined',
LOW: 'outlined',
}
interface Props {
match: Match
onCompare?: () => void
onShortlist?: () => void
onReject?: () => void
onReview?: () => void
onPipeline?: () => void
onInquire?: () => void
}
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
)
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
{engineActions.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
{engineActions.map((action, i) => (
<Box 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 }}>
{onShortlist && (
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
Zur Shortlist hinzufügen
{onInquire && (
<Button
fullWidth
size="small"
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
)}
{engineActions.length > 0 && engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant="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>
)}
{onCompare && (
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
Zum Vergleich hinzufügen
</Button>
)}
{onReview && (
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
Zur Überprüfung senden
</Button>
)}
{onReject && (
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
Match ablehnen
</Button>
)}
</Box>
</Paper>
)
@@ -16,6 +16,7 @@ import {
Train,
TrendingUp,
} from 'lucide-react'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
import {
ASSET_LABELS,
@@ -26,6 +27,7 @@ import {
SOURCE_LABELS,
UnitRow,
} from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps {
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
</Paper>
)}
{/* ── Grundriss ── */}
{(() => {
const allUnits = property.units ?? []
const plans: Array<{ url: string; label?: string }> = []
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} mb={2} />
})()}
{/* ── Beschreibung ── */}
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
@@ -270,7 +284,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/>
)}
{property.sourceUrl && (
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}>
<Button
size="small"
@@ -0,0 +1,33 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
floorPlanUrl: string
onFloorPlanUrlChange: (v: string) => void
}
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
URL zu einem Grundrissbild hilft Interessenten, sich die Fläche vorzustellen.
</Typography>
<TextField
label="Grundriss-URL"
value={floorPlanUrl}
onChange={e => onFloorPlanUrlChange(e.target.value)}
size="small"
fullWidth
placeholder="https://…"
/>
{floorPlanUrl && (
<Box
component="img"
src={floorPlanUrl}
alt="Grundriss Vorschau"
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
/>
)}
</Card>
)
}
+1
View File
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
export { ImageUrlSection } from './ImageUrlSection'
export { ContactSection } from './ContactSection'
export { CreatedScreen } from './CreatedScreen'
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
+57 -4
View File
@@ -1,13 +1,14 @@
import { useNavigate } from 'react-router'
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
import { HeatBadge } from '../shared'
import type { PipelineItem } from '../../domain/pipeline'
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
import { detailPath } from './pipelineUtils'
import { useInquiryStore } from '../../stores/inquiryStore'
// ── DraggableCard ─────────────────────────────────────────────────────────────
@@ -25,8 +26,9 @@ export function DraggableCard({
onChatClick?: (e: React.MouseEvent) => void
}) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
const stageConfig = STAGES.find(s => s.key === item.stage)!
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
const path = detailPath(item)
const style = !isDragOverlay ? {
@@ -87,7 +89,7 @@ export function DraggableCard({
</Tooltip>
)}
{path && (
<Tooltip title="Objekt öffnen">
<Tooltip title="Match öffnen">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); navigate(path) }}
@@ -135,6 +137,57 @@ export function DraggableCard({
{item.notes}
</Typography>
)}
{/* Row 6: Anfrage / Konversation */}
{!isDragOverlay && (
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
{item.stage !== 'SAVED' || item.inquiryId ? (
<Button
size="small"
variant="outlined"
startIcon={<MessagesSquare size={11} />}
fullWidth
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
borderColor: '#1e3a5f', color: '#1e3a5f',
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
}}
>
Zur Konversation
</Button>
) : (
<Button
size="small"
variant="contained"
startIcon={<Send size={11} />}
fullWidth
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: item.title,
location: item.propertyAddress ?? item.location,
areaLabel: item.areaLabel,
rentLabel: item.rentLabel,
matchScore: item.matchScore,
pipelineItemId: item.id,
})
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
}}
>
Anfrage senden
</Button>
)}
</Box>
)}
</Card>
)
}
+9 -1
View File
@@ -11,6 +11,7 @@ export function DroppableColumn({
selectedId,
onSelect,
onChatClick,
onCardChatClick,
isOver,
}: {
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
@@ -18,6 +19,7 @@ export function DroppableColumn({
selectedId: string | null
onSelect: (item: PipelineItem) => void
onChatClick: (inquiryId: string) => void
onCardChatClick?: (item: PipelineItem) => void
isOver: boolean
}) {
const { setNodeRef } = useDroppable({ id: stage.key })
@@ -43,7 +45,13 @@ export function DroppableColumn({
item={item}
isSelected={item.id === selectedId}
onSelect={onSelect}
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
onChatClick={
item.inquiryId
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
: onCardChatClick
? (e) => { e.stopPropagation(); onCardChatClick(item) }
: undefined
}
/>
))}
{items.length === 0 && (
+8 -10
View File
@@ -4,19 +4,17 @@ export { RESULT_TYPE_META } from '../../lib/ds'
// ── Stage config ──────────────────────────────────────────────────────────────
export const STAGES = [
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
] as const
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
}
+3 -3
View File
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
export { matchScoreHex as scoreColor } from '../../lib/utils'
export function detailPath(item: PipelineItem): string | null {
// propertyId is always stable across sessions — prefer it
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
if (isStableMatchId) return `/demand/results/${item.matchId}`
if (item.propertyId) return `/demand/property/${item.propertyId}`
// matchId / UUID only works in the same session (matchStore is ephemeral)
if (item.matchId) return `/demand/results/${item.matchId}`
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
return null
}
+29 -1
View File
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { resolveImageLabel } from '../../lib/propertyImageResolver'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
? result.property.location.city
: result.signal.locationHint ?? undefined
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
? resolveImageLabel({
assetType: result.property.assetType,
city: result.property.location.city,
district: result.property.location.district,
prestige: result.property.softFactors?.prestige,
floorLevel: result.property.floorLevel,
propertyId: result.property.id,
})
: null
return (
<IntelligenceMatchCard
vm={vm}
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
lat={lat}
lng={lng}
cityLabel={cityLabel}
overlayTypeLabel={overlayLabels?.type}
overlayLocationLabel={overlayLabels?.location}
/>
)
}
return <MatchCardCompact vm={vm} />
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
? result.property.images?.[0]
: undefined
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
? resolveImageLabel({
assetType: result.property.assetType,
city: result.property.location.city,
district: result.property.location.district,
prestige: result.property.softFactors?.prestige,
floorLevel: result.property.floorLevel,
propertyId: result.property.id,
})
: null
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
}
+39 -4
View File
@@ -9,11 +9,10 @@ interface Props {
address?: string
cityLabel?: string
height?: number
overlayTypeLabel?: string
overlayLocationLabel?: string
}
// TODO: replace placeholder with Google Maps Static API:
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
if (lat != null && lng != null) {
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
@@ -24,7 +23,7 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
return 'https://www.google.com/maps'
}
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
const [imgError, setImgError] = useState(false)
const mapsUrl = buildMapsUrl(lat, lng, address)
const showImage = !!imageUrl && !imgError
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
</Box>
)}
{/* Bottom-left overlay: type + location */}
{(overlayTypeLabel || overlayLocationLabel) && (
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
px: 1.25,
py: 0.75,
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
display: 'flex',
alignItems: 'flex-end',
gap: 0.75,
}}
>
{overlayTypeLabel && (
<Typography
sx={{
fontSize: '0.68rem', fontWeight: 700, color: 'white',
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
letterSpacing: 0.3,
}}
>
{overlayTypeLabel}
</Typography>
)}
{overlayLocationLabel && (
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
{overlayLocationLabel}
</Typography>
)}
</Box>
)}
{/* Google Maps button */}
<Tooltip title="In Google Maps öffnen" placement="top">
<IconButton
+1 -1
View File
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
matchId: item.resultId,
needId: '',
matchScore: item.matchScore,
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK',
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
}
@@ -11,7 +11,6 @@ import type { ShortlistItem } from '../../domain/shortlist'
const RESULT_TYPE_LABEL: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Direkt',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Signal',
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
GASTRO: 'Gastro',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
LIGHT_INDUSTRIAL: 'Leichtindustrie',
LIGHT_INDUSTRIAL: 'Gewerbe',
UNKNOWN: 'Unbekannt',
}
return labels[type] ?? type
-1
View File
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
// ── Result Types ──────────────────────────────────────────────────────────────
export const ResultType = {
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
MAISON_WORK: 'MAISON_WORK',
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
} as const
+3
View File
@@ -28,6 +28,9 @@ export interface Inquiry {
tenantName: string
tenantCompany?: string
tenantEmail?: string
propertyManagerName?: string
propertyManagerCompany?: string
propertyAddress?: string
subject: string
message: string
status?: InquiryStatus
+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 {
id: string
title: string
location: string
matchScore: number
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
stage: PipelineStage
// Source match reference — used for navigating back to the detail page
matchId?: string
+1
View File
@@ -134,6 +134,7 @@ export interface Property {
riskLevel?: RiskLevel
description?: string
images?: string[]
floorPlanUrl?: string
propertyNumber?: string
units?: PropertyUnit[]
-1
View File
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
export const CONFIDENCE_MODIFIER = {
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
+1 -9
View File
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
}
export interface VerifiedPortfolioResult extends UnifiedResultBase {
resultType: 'VERIFIED_PORTFOLIO'
property: Property
match: Match
unit?: PropertyUnit // specific unit this match refers to
}
export interface ExternalMarketResult extends UnifiedResultBase {
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
property: Property
match: Match
unit?: PropertyUnit // specific unit this match refers to
@@ -38,5 +31,4 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
export type UnifiedMatchResult =
| VerifiedPortfolioResult
| ExternalMarketResult
| FutureAvailabilityResult
+1
View File
@@ -16,6 +16,7 @@ export interface PropertyUnit {
currentTenant?: string
leaseTerm?: string
leaseEndDate?: string
floorPlanUrl?: string // optional floor plan image
// Flexible letting (Teilfläche)
isFlexible?: boolean
minLettableSqm?: number
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
expect(ranked[0].propertyId).toBe('high')
})
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => {
it('breaks ties by result type: VERIFIED_PORTFOLIO and MAISON_WORK ranked above FUTURE_AVAILABILITY', () => {
// Build one of each type but force identical score by using same property body
const prop = makeProperty()
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 })
const future = buildFullMatch(need, { ...prop, id: 'f', resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.85 })
// If scores differ, force them equal for a clean tiebreak test
if (verified.matchScore !== external.matchScore) {
const lower = Math.min(verified.matchScore, external.matchScore)
verified.matchScore = lower
external.matchScore = lower
}
// Force same score for a clean tiebreak test
verified.matchScore = 75
future.matchScore = 75
const ranked = rankMatches([external, verified])
const ranked = rankMatches([future, verified])
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
})
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
expect(calcConfidenceModifier(p)).toBe(0)
})
it('returns -3 for external market results', () => {
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
expect(calcConfidenceModifier(p)).toBe(-3)
})
it('returns -2 for Maison Work results', () => {
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
expect(calcConfidenceModifier(p)).toBe(-2)
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
})
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
expect(calcConfidenceModifier(p)).toBe(-13)
// MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
expect(calcConfidenceModifier(p)).toBe(-12)
})
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
expect(output.finalScore).toBeGreaterThanOrEqual(85)
})
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => {
it('scores a weak match (wrong city, over budget, poor DQ, low confidence) below 50', () => {
const need = makeNeed({ preferredLocations: ['Zürich'] })
const prop = makeProperty({
location: { city: 'Basel', country: 'CH' },
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
dataQuality: {
score: 0.30, // CRITICAL → -15
@@ -132,7 +132,7 @@ describe('softFactorEnrichmentService — score range invariant (all keys, 01
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
it('returns identical estimate for same location regardless of resultType', () => {
// The enrichment service is location-only — resultType is irrelevant.
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
// FUTURE_AVAILABILITY (pre-market) and VERIFIED_PORTFOLIO should produce the same soft factor score.
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
@@ -141,16 +141,16 @@ describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
address: sharedAddress,
resultType: ResultType.FUTURE_AVAILABILITY,
})
const marketResult = makeProperty({
const portfolioResult = makeProperty({
location: sharedLocation,
address: sharedAddress,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
})
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
expect(futureEst?.score).toBe(marketEst?.score)
expect(futureEst?.label).toBe(marketEst?.label)
const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
expect(futureEst?.score).toBe(portfolioEst?.score)
expect(futureEst?.label).toBe(portfolioEst?.label)
})
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
+4 -4
View File
@@ -191,10 +191,10 @@ export function rankMatches(matches: Match[]): Match[] {
return [...matches].sort((a, b) => {
// Primary: matchScore descending
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
// Secondary: VERIFIED / MAISON_WORK > FUTURE
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
if (aOrder !== bOrder) return aOrder - bOrder
// Tertiary: higher confidence first
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
+4 -15
View File
@@ -499,8 +499,6 @@ export function calcConfidenceModifier(property: Property): number {
let mod = 0
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
} else if (property.resultType === ResultType.MAISON_WORK) {
mod += CONFIDENCE_MODIFIER.MAISON_WORK
} else {
@@ -586,17 +584,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Modifiers: data quality and confidence adjust the final score ─────────
// These are intentionally applied after the hard/soft weighted sum so that
// a low-confidence or poor-data property can be penalised without distorting
// the individual criterion breakdown.
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
+ dataQualityModifier + confidenceModifier
// ── Final score: hard/soft weighted sum, clamped 0100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -624,8 +613,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier,
confidenceModifier,
dataQualityModifier: 0,
confidenceModifier: 0,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
+7 -4
View File
@@ -52,6 +52,7 @@ export interface NewListingFormState {
// Images
images: string[]
imageInput: string
floorPlanUrl: string
// AI
aiText: string
aiApplied: boolean
@@ -82,6 +83,7 @@ export interface NewListingFormHandlers {
setContactEmail: (v: string) => void
setContactPhone: (v: string) => void
setImageInput: (v: string) => void
setFloorPlanUrl: (v: string) => void
setAiText: (v: string) => void
addImage: () => void
removeImage: (index: number) => void
@@ -113,6 +115,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [ceilingHeight, setCeilingHeight]= useState('')
const [images, setImages] = useState<string[]>([])
const [imageInput, setImageInput] = useState('')
const [floorPlanUrl, setFloorPlanUrl] = useState('')
const [aiText, setAiText] = useState('')
const [aiApplied, setAiApplied] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -157,7 +160,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
}),
{
onSuccess: () => setCreated(true),
@@ -173,7 +176,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setImages([]); setImageInput('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
setCreated(false); setError(null)
}
@@ -183,7 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight,
contactName, contactEmail, contactPhone,
images, imageInput, aiText, aiApplied,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
aiParsing: parseListingMutation.isPending,
submitting: createProperty.isPending,
@@ -193,7 +196,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight,
setContactName, setContactEmail, setContactPhone,
setImageInput, setAiText,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
handleAiParse, handleSubmit, resetForm,
}
+3 -17
View File
@@ -6,17 +6,16 @@ import { useSchattenmarktSignals } from './useSchattenmarktSignals'
import type {
UnifiedMatchResult,
VerifiedPortfolioResult,
ExternalMarketResult,
FutureAvailabilityResult,
} from '../domain/unifiedResult'
export function useUnifiedResults(needId?: string) {
const allMatchesQuery = useMatches()
const needMatchesQuery = useMatchesByNeed(needId ?? '')
const propertiesQuery = useProperties()
const signalsQuery = useFutureSignals()
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
// Always scope to a specific need — showing cross-need results causes duplicates per property
const matchesQuery = needMatchesQuery
const isLoading =
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
@@ -64,24 +63,11 @@ export function useUnifiedResults(needId?: string) {
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
: undefined
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
const result: ExternalMarketResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
property,
match,
unit: matchUnit,
}
return [result]
}
const result: VerifiedPortfolioResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: 'VERIFIED_PORTFOLIO',
resultType: rt as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
property,
match,
unit: matchUnit,
+1 -1
View File
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
RETAIL: 'Retail',
GASTRO: 'Gastronomie',
LOGISTICS: 'Logistik',
LIGHT_INDUSTRIAL: 'Gewerbe',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
}
@@ -69,7 +70,6 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
// Result type display labels
export const RESULT_TYPE_LABELS: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Plattform',
EXTERNAL_MARKET: 'Plattform',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Zukunftssignal',
}
-2
View File
@@ -7,7 +7,6 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
export const DS_COLORS = {
resultType: {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
EXTERNAL_MARKET: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
},
@@ -199,7 +198,6 @@ export function scoreToDataQualityLevel(score: number): DataQualityLevel {
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
EXTERNAL_MARKET: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
}
-1
View File
@@ -99,7 +99,6 @@ export function canViewProperty(
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
return property.organizationId === user.organizationId
}
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.MAISON_WORK) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
+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',
matchScore: 87,
resultType: 'VERIFIED_PORTFOLIO',
stage: 'QUALIFIED',
stage: 'CONTACTED',
areaLabel: '1200 m²',
rentLabel: 'CHF 38/m²',
availabilityLabel: '2026-10-01',
@@ -74,7 +74,7 @@ export const mockPipelineItems: PipelineItem[] = [
location: 'Zürich-West / Technopark',
matchScore: 76,
resultType: 'FUTURE_AVAILABILITY',
stage: 'QUALIFIED',
stage: 'CONTACTED',
areaLabel: '~600 m²',
availabilityLabel: '~10 Monate',
addedAt: '2026-05-06T10:00:00Z',
@@ -121,7 +121,7 @@ export const mockPipelineItems: PipelineItem[] = [
title: 'Gewerbe Güterstrasse Basel',
location: 'Basel',
matchScore: 68,
resultType: 'EXTERNAL_MARKET',
resultType: 'VERIFIED_PORTFOLIO',
stage: 'CLOSED_LOST',
areaLabel: '800 m²',
rentLabel: 'CHF 28/m²',
+44 -23
View File
@@ -1,5 +1,6 @@
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
import type { Property } from '../domain/property'
import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
export const mockProperties: Property[] = [
@@ -1071,14 +1072,14 @@ export const mockProperties: Property[] = [
},
// ─────────────────────────────────────────────────────────────────────────────
// EXTERNAL_MARKET (10)
// VERIFIED_PORTFOLIO — formerly external market (migrated)
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-003',
title: 'Retail-Fläche Bahnhofstrasse 88',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 320,
@@ -1122,7 +1123,7 @@ export const mockProperties: Property[] = [
id: 'prop-004',
title: 'Gemischte Gewerbeeinheit Europaallee',
assetType: AssetType.MIXED,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
areaSqm: 1150,
@@ -1246,7 +1247,7 @@ export const mockProperties: Property[] = [
id: 'prop-017',
title: 'Ladenfläche Löwenstrasse 28',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 350,
@@ -1290,7 +1291,7 @@ export const mockProperties: Property[] = [
id: 'prop-018',
title: 'Bürofläche Breitenrain 14',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
areaSqm: 780,
@@ -1458,7 +1459,7 @@ export const mockProperties: Property[] = [
id: 'prop-022',
title: 'Bürofläche St.Gallen Centrum 7',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
areaSqm: 700,
@@ -1666,7 +1667,7 @@ export const mockProperties: Property[] = [
id: 'prop-031',
title: 'Bürofläche Hardturm West 16',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 780,
@@ -1780,7 +1781,7 @@ export const mockProperties: Property[] = [
id: 'prop-034',
title: 'Ladenfläche Lorrainestrasse 8',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
areaSqm: 340,
@@ -1844,7 +1845,7 @@ export const mockProperties: Property[] = [
id: 'prop-036',
title: 'Lagerhalle Klybeckstrasse 280',
assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2600,
@@ -2206,7 +2207,7 @@ export const mockProperties: Property[] = [
id: 'prop-055',
title: 'Ladenfläche Weststrasse 44',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
areaSqm: 150,
@@ -2254,7 +2255,7 @@ export const mockProperties: Property[] = [
id: 'prop-056',
title: 'Gewerbefläche Hohlstrasse 88',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
areaSqm: 155,
@@ -2667,7 +2668,7 @@ export const mockProperties: Property[] = [
id: 'prop-064',
title: 'Bürofläche Förrlibuckstrasse 22',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 185,
@@ -2714,7 +2715,7 @@ export const mockProperties: Property[] = [
id: 'prop-065',
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
areaSqm: 175,
@@ -2813,7 +2814,7 @@ export const mockProperties: Property[] = [
id: 'prop-067',
title: 'Bürofläche Altstetterstrasse 222',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 190,
@@ -3175,7 +3176,7 @@ export const mockProperties: Property[] = [
id: 'prop-074',
title: 'Bürofläche Uraniastrasse 14',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 510,
@@ -3223,7 +3224,7 @@ export const mockProperties: Property[] = [
id: 'prop-075',
title: 'Bürofläche Seefeldstrasse 30',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
areaSqm: 540,
@@ -3483,7 +3484,7 @@ export const mockProperties: Property[] = [
id: 'prop-080',
title: 'Ladenfläche Spitalgasse 34',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 280,
@@ -3531,7 +3532,7 @@ export const mockProperties: Property[] = [
id: 'prop-081',
title: 'Ladenfläche Münstergasse 12',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 350,
@@ -3838,7 +3839,7 @@ export const mockProperties: Property[] = [
id: 'prop-087',
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
areaSqm: 3000,
@@ -3885,7 +3886,7 @@ export const mockProperties: Property[] = [
id: 'prop-088',
title: 'Lagerhalle Westquaistrasse 12',
assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2600,
@@ -4091,7 +4092,7 @@ export const mockProperties: Property[] = [
id: 'prop-092',
title: 'Bürofläche Hohlstrasse 534, Altstetten',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 900,
@@ -4138,7 +4139,7 @@ export const mockProperties: Property[] = [
id: 'prop-093',
title: 'Bürofläche Technopark Zürich-West',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 960,
@@ -4343,7 +4344,7 @@ export const mockProperties: Property[] = [
id: 'prop-097',
title: 'Bürofläche Baarerstrasse 18, Zug',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 320,
@@ -4487,3 +4488,23 @@ export const mockProperties: Property[] = [
},
]
// Assign images sequentially per pool so no two properties ever share the same image
// (until pool wraps, which happens after ~12 properties per pool type)
const _poolCounters: Record<string, number> = {}
mockProperties.forEach(p => {
const district = (p.location.district ?? '').toLowerCase()
const prestige = p.softFactors?.prestige ?? 60
const key = getPoolKey(p.assetType, district, prestige)
const poolIndex = _poolCounters[key] ?? 0
_poolCounters[key] = poolIndex + 1
p.images = [resolvePropertyImage({
assetType: p.assetType,
city: p.location.city,
district: p.location.district,
prestige: p.softFactors?.prestige,
floorLevel: p.floorLevel,
propertyId: p.id,
poolIndex,
})]
})
+25 -11
View File
@@ -5,7 +5,8 @@ import {
InputAdornment, Alert,
} from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockInquiries } from '../../mock-data/inquiries'
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useToastStore } from '../../stores/toastStore'
import type { InquiryMessage } from '../../domain/inquiry'
@@ -13,6 +14,7 @@ import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
import { useSessionStore } from '../../stores/sessionStore'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -34,9 +36,13 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry')
const [inquiries, setInquiries] = useState(mockInquiries)
const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries)
const [inquiries, setInquiries] = useState(mockDemandInquiries)
const allInquiries = [...storeInquiries, ...inquiries]
const [selectedId, setSelectedId] = useState<string | null>(
preselectedId ?? mockInquiries[0]?.id ?? null
preselectedId ?? mockDemandInquiries[0]?.id ?? null
)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
@@ -45,18 +51,21 @@ export default function Anfragen() {
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
const threadRef = useRef<HTMLDivElement>(null)
const filtered = inquiries.filter(inq => {
const filtered = allInquiries.filter(inq => {
const q = search.toLowerCase()
const matchesSearch = !q ||
inq.tenantName.toLowerCase().includes(q) ||
inq.subject.toLowerCase().includes(q) ||
(inq.tenantCompany?.toLowerCase().includes(q) ?? false)
(inq.tenantCompany?.toLowerCase().includes(q) ?? false) ||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
return matchesSearch && matchesStatus
})
const selected = inquiries.find(i => i.id === selectedId) ?? null
const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0)
const selected = allInquiries.find(i => i.id === selectedId) ?? null
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
// Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId
@@ -176,6 +185,7 @@ export default function Anfragen() {
inq={inq}
isSelected={inq.id === selectedId}
hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
perspective="demand"
onSelect={handleSelect}
/>
))}
@@ -205,12 +215,16 @@ export default function Anfragen() {
<ArrowLeft size={16} />
</IconButton>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}>
{selected.tenantName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
{(selected.propertyAddress ?? selected.subject).split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.tenantName}</Typography>
{selected.tenantCompany && <Typography variant="caption" color="text.secondary">{selected.tenantCompany}</Typography>}
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.9rem' }}>{selected.propertyAddress ?? selected.subject}</Typography>
{(selected.propertyManagerCompany ?? selected.propertyManagerName) && (
<Typography variant="caption" color="text.secondary">
{selected.propertyManagerCompany ?? selected.propertyManagerName ?? ''}
</Typography>
)}
</Box>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
{selected.subject}
@@ -281,7 +295,7 @@ export default function Anfragen() {
{/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => (
<AnfragenMessageBubble key={msg.id} msg={msg} />
<AnfragenMessageBubble key={msg.id} msg={msg} currentUserName={currentUser?.name ?? undefined} />
))}
</Box>
+14 -5
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { PropertyMap } from '../../components/shared'
@@ -46,6 +48,7 @@ export default function MatchDetail() {
const navigate = useNavigate()
const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore()
const { openInquiryDialog } = useInquiryStore()
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
@@ -88,7 +91,7 @@ export default function MatchDetail() {
const handleCompare = () => {
if (match && !isFuture && property) {
addToCompare({
resultType: property.resultType === 'EXTERNAL_MARKET' ? 'EXTERNAL_MARKET' : 'VERIFIED_PORTFOLIO',
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
})
} else if (match && isFuture && signal) {
@@ -126,6 +129,7 @@ export default function MatchDetail() {
return (
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
<AddToPipelineDialog />
<InquiryQuickDialog />
{/* Sticky back nav */}
<Box sx={{
@@ -164,7 +168,7 @@ export default function MatchDetail() {
/>
{/* ── Main content ── */}
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}>
@@ -196,9 +200,14 @@ export default function MatchDetail() {
<NextActionsPanel
match={match}
onCompare={handleCompare}
onShortlist={handleShortlist}
onReview={() => {}}
onReject={() => {}}
onPipeline={handleShortlist}
onInquire={() => openInquiryDialog({
propertyTitle: title,
location,
areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : undefined,
rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
matchScore: match.matchScore,
})}
/>
{/* ── Full analysis toggle ── */}
+10
View File
@@ -9,7 +9,9 @@ import {
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { TrendingUp } from 'lucide-react'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
import { STAGES } from '../../components/pipeline/pipelineConstants'
import { DraggableCard } from '../../components/pipeline/PipelineCard'
@@ -22,6 +24,7 @@ export default function Pipeline() {
const navigate = useNavigate()
const { data: items = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [overId, setOverId] = useState<string | null>(null)
@@ -66,6 +69,7 @@ export default function Pipeline() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog />
<InquiryQuickDialog />
{/* Header */}
<Box sx={{
@@ -123,6 +127,12 @@ export default function Pipeline() {
selectedId={syncedSelected?.id ?? null}
onSelect={handleSelect}
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
onCardChatClick={(item) => openInquiryDialog({
propertyTitle: item.title,
location: item.location ?? '',
matchScore: item.matchScore ?? 0,
propertyId: item.propertyId,
})}
isOver={isOver}
/>
</Box>
+5 -3
View File
@@ -14,6 +14,7 @@ import {
UnifiedResultFeed,
} from '../../components/results'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { ErrorState } from '../../components/ui'
import { useSessionStore } from '../../stores/sessionStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -65,7 +66,8 @@ export default function Results() {
}
}, [activeNeedIdFromNav, queryClient])
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
const needsLoading = allNeeds.length === 0 && !activeNeedIdFromNav
const { data: results = [], isLoading, error } = useUnifiedResults(needsLoading ? undefined : effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
@@ -73,7 +75,7 @@ export default function Results() {
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0, missing = 0
for (const r of results) {
if (r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET') platform++
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
else if (r.resultType === 'MAISON_WORK') maison++
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
@@ -91,7 +93,6 @@ export default function Results() {
return filterSource === 'ALL' || filterSource === 'PLATFORM'
}
if (filterSource === 'ALL') return true
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
return r.resultType === filterSource
})
return sortResults(filtered, sortBy)
@@ -102,6 +103,7 @@ export default function Results() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog />
<InquiryQuickDialog />
<ResultFeedHeader
total={sorted.length}
platformCount={platformCount}
+2 -2
View File
@@ -1,11 +1,11 @@
import type { PipelineStage } from '../../domain/pipeline'
export const STAGE_ORDER: PipelineStage[] = [
'SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
'SAVED', 'CONTACTED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
]
export const STAGE_LABELS: Record<string, string> = {
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
SAVED: 'Interessiert', CONTACTED: 'Angefragt',
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
}
+1 -1
View File
@@ -22,7 +22,7 @@ import type { Property } from '../../domain/property'
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
LIGHT_INDUSTRIAL: 'Leichtindustrie',
LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
+6
View File
@@ -9,6 +9,7 @@ import {
SoftFactorsSection,
TechnicalDetailsSection,
ImageUrlSection,
FloorPlanUrlSection,
ContactSection,
CreatedScreen,
} from '../../components/new-listing'
@@ -92,6 +93,11 @@ export default function NewListing() {
onRemove={form.removeImage}
/>
<FloorPlanUrlSection
floorPlanUrl={form.floorPlanUrl}
onFloorPlanUrlChange={form.setFloorPlanUrl}
/>
<ContactSection
name={form.contactName} onNameChange={form.setContactName}
email={form.contactEmail} onEmailChange={form.setContactEmail}
+1 -1
View File
@@ -1,7 +1,7 @@
export const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
LIGHT_INDUSTRIAL: 'Leichtindustrie',
LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
+4 -2
View File
@@ -18,11 +18,12 @@ export function buildCreatePropertyInput(fields: {
parking: string
ceilingHeight: string
images: string[]
floorPlanUrl: string
}): CreatePropertyInput {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
} = fields
const sf = {
@@ -47,7 +48,7 @@ export function buildCreatePropertyInput(fields: {
return {
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
assetType: assetType as typeof AssetType[keyof typeof AssetType],
resultType: ResultType.EXTERNAL_MARKET,
resultType: ResultType.VERIFIED_PORTFOLIO,
sourceType: 'DIRECT',
location: { city, country: 'CH' },
address: {
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
softFactors: sf,
hardFacts: hf,
images: images.length > 0 ? images : undefined,
floorPlanUrl: floorPlanUrl.trim() || undefined,
dataQuality: {
score: 1.0,
missingCriticalFields: [],
+1 -1
View File
@@ -1,7 +1,7 @@
export interface DashboardStats {
totalProperties: number
verifiedProperties: number
externalMarketProperties: number
verifiedPortfolioProperties: number
futureSignalProperties: number
totalMatches: number
pendingReviews: number
+1 -1
View File
@@ -32,7 +32,7 @@ export const MockupDashboardProvider: IDashboardProvider = {
const stats: DashboardStats = {
totalProperties: props.length,
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
externalMarketProperties: props.filter(p => p.resultType === 'EXTERNAL_MARKET').length,
verifiedPortfolioProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
totalMatches: matches.length,
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
+4 -1
View File
@@ -1,7 +1,7 @@
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
import type { Match } from '../domain/match'
// Matches are computed dynamically via calculateScore so weights always reflect need.weightingProfile
// Populated at startup by MockupNeedProvider via syncMatchesForNeed (deterministic IDs, no duplicates)
export const matchStore: Match[] = []
const store = matchStore
@@ -13,6 +13,9 @@ export const MockupMatchProvider: IMatchProvider = {
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
// Deduplicate by id — guards against double-push during dev hot-reload
const seen = new Set<string>()
results = results.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true })
return results.sort((a, b) => b.matchScore - a.matchScore)
},
async getById(id) {
+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) {
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 STAGE_MIGRATION: Record<string, PipelineStage> = {
DISCOVERED: 'CONTACTED',
QUALIFIED: 'CONTACTED',
}
function migrateStage(stage: string): PipelineStage {
return (STAGE_MIGRATION[stage] ?? stage) as PipelineStage
}
function load(): PipelineItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as PipelineItem[]
if (raw) {
const items = JSON.parse(raw) as PipelineItem[]
return items.map(i => ({ ...i, stage: migrateStage(i.stage) }))
}
} catch { /* ignore */ }
return [...mockPipelineItems]
}
+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
const ZURICH_ONLY_SIGNALS = [
'industrie groove', 'industrie-groove',
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
'pfingstweidstrasse', 'freilager', 'europaallee',
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
'hürlimann', 'viadukt', 'schiffbau',
]
+7 -4
View File
@@ -32,7 +32,7 @@ function buildMatch(
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
return {
id: crypto.randomUUID(),
id: `m__${prop.id}__${unitId ?? 'prop'}__${need.id}`,
propertyId: prop.id,
unitId,
needId: need.id,
@@ -92,9 +92,12 @@ export function generateMatchesForNeed(need: Need): void {
const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) {
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))
const allPreMarket = prop.units!.every(u => u.schattenmarktRelease?.enabled)
if (!allPreMarket) {
const output = scoreProperty(need, prop)
if (!output.excluded && output.finalScore >= MIN_SCORE) {
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
}
}
for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue
+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] }))
},
}))