diff --git a/src/components/demand/AnfragenMessageBubble.tsx b/src/components/demand/AnfragenMessageBubble.tsx
index 1cb24f0..d34c258 100644
--- a/src/components/demand/AnfragenMessageBubble.tsx
+++ b/src/components/demand/AnfragenMessageBubble.tsx
@@ -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 && }
{!isOwnMessage && msg.senderType === 'supply_user' && }
- {msg.senderName} · {date} {time}
+ {isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
= {
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',
}
diff --git a/src/components/demand/NeedCardPreview.tsx b/src/components/demand/NeedCardPreview.tsx
index 40ea99e..5615261 100644
--- a/src/components/demand/NeedCardPreview.tsx
+++ b/src/components/demand/NeedCardPreview.tsx
@@ -15,7 +15,7 @@ interface Props {
const ASSET_LABELS: Record = {
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']
diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx
index 9d060c6..9ddb5a9 100644
--- a/src/components/demand/NeedInput.tsx
+++ b/src/components/demand/NeedInput.tsx
@@ -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 },
]
diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx
index dbec8a2..a45f14f 100644
--- a/src/components/match-card/IntelligenceMatchCard.tsx
+++ b/src/components/match-card/IntelligenceMatchCard.tsx
@@ -20,9 +20,11 @@ interface Props {
lat?: number
lng?: number
cityLabel?: string
+ overlayTypeLabel?: string
+ overlayLocationLabel?: string
}
-export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
+export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore()
@@ -53,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{/* ── Hero zone ── */}
-
+
+
- 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 (
-
- {/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
- {vm.disclaimer && (
-
- {vm.disclaimer}
-
- )}
+
- {vm.isReviewRequired && (
-
- Manuelle Überprüfung erforderlich
-
- )}
+ {/* ── Image strip ── */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
- {/* Header: score → type → confidence → availability → risk */}
-
+ {/* Score badge */}
+
+
+ {vm.matchScore}%
+
+
- {/* Title + location (max 2 lines) */}
-
-
+ {/* Asset type label */}
+ {overlayTypeLabel && (
+
+
+ {overlayTypeLabel}
+
+
+ )}
+
+
+ {/* ── Content ── */}
+
+
+ {/* Alerts */}
+ {vm.disclaimer && (
+ {vm.disclaimer}
+ )}
+
+
+ {/* Badges row */}
+
+
+
+
+ {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
+ } 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 } }} />
+ )}
+
+ {vm.availabilityLabel && (
+
+ )}
+ {topRisk && (
+
+ )}
+
+
+
+ {/* Title + location */}
+
{vm.title}
{vm.propertySubtitle && (
-
+
{vm.propertySubtitle}
)}
-
-
-
+
+
+
{vm.locationLabel}
+
+ {/* Explainability summary */}
{vm.explainabilitySummary && (
-
+
{vm.explainabilitySummary}
)}
+
+
+
+ {/* Score breakdown */}
+ {vm.scoreBreakdown && (
+
+
+
+ )}
+
+ {/* Reasons — all 3, same style as grid card */}
+ {vm.reasons.length > 0 && (
+
+ {vm.reasons.slice(0, 3).map((r, i) => (
+
+
+
+
+ {r.label}
+
+
+ {r.explanation}
+
+
+
+ ))}
+
+ )}
+
+ {/* Top tradeoff */}
+ {vm.tradeoffs.length > 0 && (
+
+
+
+ {vm.tradeoffs[0].description}
+
+
+ )}
+
+ {/* Actions */}
+
+
+ {vm.actions.map(a => (
+
+ ))}
+
-
-
-
- {/* Score formula — always visible */}
- {vm.scoreBreakdown && (
-
-
-
- )}
-
- {/* Top reason only */}
- {vm.reasons.length > 0 && (
-
-
-
- )}
-
- {/* Top tradeoff only (compact) */}
- {vm.tradeoffs.length > 0 && (
-
-
-
- )}
-
- {/* Data quality — only critical warning in compact mode */}
-
-
-
-
-
-
+
)
-}
+})
diff --git a/src/components/match-detail/FloorPlanSection.tsx b/src/components/match-detail/FloorPlanSection.tsx
new file mode 100644
index 0000000..499de04
--- /dev/null
+++ b/src/components/match-detail/FloorPlanSection.tsx
@@ -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 (
+
+
+
+ Grundriss
+
+
+ {plans.map((p, i) => (
+
+ {p.label && (
+
+ {p.label}
+
+ )}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/match-detail/InquiryQuickDialog.tsx b/src/components/match-detail/InquiryQuickDialog.tsx
index d980920..b268594 100644
--- a/src/components/match-detail/InquiryQuickDialog.tsx
+++ b/src/components/match-detail/InquiryQuickDialog.tsx
@@ -6,6 +6,7 @@ import {
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
+import { useMoveStage } from '../../hooks/usePipeline'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,6 +20,7 @@ export function InquiryQuickDialog() {
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
+ const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
@@ -31,6 +33,9 @@ export function InquiryQuickDialog() {
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
+ if (pendingInquiry.pipelineItemId) {
+ moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
+ }
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
diff --git a/src/components/match-detail/MatchDetailPropertySections.tsx b/src/components/match-detail/MatchDetailPropertySections.tsx
index 65c03ed..1cb3a50 100644
--- a/src/components/match-detail/MatchDetailPropertySections.tsx
+++ b/src/components/match-detail/MatchDetailPropertySections.tsx
@@ -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['data']>
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
)}
+ {/* 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
+ })()}
+
{/* Beschreibung */}
{property.description && (
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.dataQuality.lastVerifiedAt && (
)}
- {property.sourceUrl && (
+ {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat
diff --git a/src/components/match-detail/PropertyDetailPublicSections.tsx b/src/components/match-detail/PropertyDetailPublicSections.tsx
index 362e460..8fd0d5c 100644
--- a/src/components/match-detail/PropertyDetailPublicSections.tsx
+++ b/src/components/match-detail/PropertyDetailPublicSections.tsx
@@ -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
)}
+ {/* ── 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
+ })()}
+
{/* ── Beschreibung ── */}
{property.description && (
@@ -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 && (
+ ) : (
+ }
+ 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
+
+ )}
+
+ )}
)
}
diff --git a/src/components/pipeline/pipelineConstants.ts b/src/components/pipeline/pipelineConstants.ts
index b2f9866..fc84b1b 100644
--- a/src/components/pipeline/pipelineConstants.ts
+++ b/src/components/pipeline/pipelineConstants.ts
@@ -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> = {
- 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' },
}
diff --git a/src/components/pipeline/pipelineUtils.ts b/src/components/pipeline/pipelineUtils.ts
index 4fc8b43..d957a34 100644
--- a/src/components/pipeline/pipelineUtils.ts
+++ b/src/components/pipeline/pipelineUtils.ts
@@ -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
}
diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx
index 868be47..84e634a 100644
--- a/src/components/results/UnifiedResultCard.tsx
+++ b/src/components/results/UnifiedResultCard.tsx
@@ -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 (
)
}
- return
+ 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
}
diff --git a/src/components/shared/LocationPreview.tsx b/src/components/shared/LocationPreview.tsx
index 5d003ea..1d753ce 100644
--- a/src/components/shared/LocationPreview.tsx
+++ b/src/components/shared/LocationPreview.tsx
@@ -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
)}
+ {/* Bottom-left overlay: type + location */}
+ {(overlayTypeLabel || overlayLocationLabel) && (
+
+ {overlayTypeLabel && (
+
+ {overlayTypeLabel}
+
+ )}
+ {overlayLocationLabel && (
+
+ {overlayLocationLabel}
+
+ )}
+
+ )}
+
{/* Google Maps button */}
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([])
const [imageInput, setImageInput] = useState('')
+ const [floorPlanUrl, setFloorPlanUrl] = useState('')
const [aiText, setAiText] = useState('')
const [aiApplied, setAiApplied] = useState(false)
const [error, setError] = useState(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,
}
diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts
index d04b3df..4d6b7c2 100644
--- a/src/hooks/useUnifiedResults.ts
+++ b/src/hooks/useUnifiedResults.ts
@@ -10,12 +10,12 @@ import type {
} from '../domain/unifiedResult'
export function useUnifiedResults(needId?: string) {
- const allMatchesQuery = useMatches()
const needMatchesQuery = useMatchesByNeed(needId ?? '')
const propertiesQuery = useProperties()
const signalsQuery = useFutureSignals()
- const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
+ // Always scope to a specific need — showing cross-need results causes duplicates per property
+ const matchesQuery = needMatchesQuery
const isLoading =
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 704f1f2..cd69f9f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record = {
RETAIL: 'Retail',
GASTRO: 'Gastronomie',
LOGISTICS: 'Logistik',
+ LIGHT_INDUSTRIAL: 'Gewerbe',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
}
diff --git a/src/lib/propertyImageResolver.ts b/src/lib/propertyImageResolver.ts
new file mode 100644
index 0000000..cfde0ee
--- /dev/null
+++ b/src/lib/propertyImageResolver.ts
@@ -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 = (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> = {
+ [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 }
+}
diff --git a/src/mock-data/pipelineItems.ts b/src/mock-data/pipelineItems.ts
index 2cc0915..66dedf1 100644
--- a/src/mock-data/pipelineItems.ts
+++ b/src/mock-data/pipelineItems.ts
@@ -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',
diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts
index 26fdac8..03cb465 100644
--- a/src/mock-data/properties.ts
+++ b/src/mock-data/properties.ts
@@ -1,5 +1,6 @@
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
import type { Property } from '../domain/property'
+import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
export const mockProperties: Property[] = [
@@ -4487,3 +4488,23 @@ export const mockProperties: Property[] = [
},
]
+
+// Assign images sequentially per pool so no two properties ever share the same image
+// (until pool wraps, which happens after ~12 properties per pool type)
+const _poolCounters: Record = {}
+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,
+ })]
+})
diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx
index 87aafed..9256b51 100644
--- a/src/pages/demand/Anfragen.tsx
+++ b/src/pages/demand/Anfragen.tsx
@@ -14,6 +14,7 @@ import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
+import { useSessionStore } from '../../stores/sessionStore'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -35,6 +36,7 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry')
+ const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries)
const [inquiries, setInquiries] = useState(mockDemandInquiries)
const allInquiries = [...storeInquiries, ...inquiries]
@@ -293,7 +295,7 @@ export default function Anfragen() {
{/* Thread */}
{selected.thread.map(msg => (
-
+
))}
diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx
index cd3d1f3..637311a 100644
--- a/src/pages/demand/Results.tsx
+++ b/src/pages/demand/Results.tsx
@@ -66,7 +66,8 @@ export default function Results() {
}
}, [activeNeedIdFromNav, queryClient])
- const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
+ const needsLoading = allNeeds.length === 0 && !activeNeedIdFromNav
+ const { data: results = [], isLoading, error } = useUnifiedResults(needsLoading ? undefined : effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
diff --git a/src/pages/demand/anfragenKiDetection.ts b/src/pages/demand/anfragenKiDetection.ts
index d3ca764..06a3190 100644
--- a/src/pages/demand/anfragenKiDetection.ts
+++ b/src/pages/demand/anfragenKiDetection.ts
@@ -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 = {
- SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
+ SAVED: 'Interessiert', CONTACTED: 'Angefragt',
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
}
diff --git a/src/pages/supply/MyListings.tsx b/src/pages/supply/MyListings.tsx
index 4129ae4..98f3e82 100644
--- a/src/pages/supply/MyListings.tsx
+++ b/src/pages/supply/MyListings.tsx
@@ -22,7 +22,7 @@ import type { Property } from '../../domain/property'
const ASSET_LABELS: Record = {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
- LIGHT_INDUSTRIAL: 'Leichtindustrie',
+ LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx
index 65b1a0f..20bd8ee 100644
--- a/src/pages/supply/NewListing.tsx
+++ b/src/pages/supply/NewListing.tsx
@@ -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}
/>
+
+
= {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
- LIGHT_INDUSTRIAL: 'Leichtindustrie',
+ LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
diff --git a/src/pages/supply/newListingMapper.ts b/src/pages/supply/newListingMapper.ts
index 68597a9..4921e24 100644
--- a/src/pages/supply/newListingMapper.ts
+++ b/src/pages/supply/newListingMapper.ts
@@ -18,11 +18,12 @@ export function buildCreatePropertyInput(fields: {
parking: string
ceilingHeight: string
images: string[]
+ floorPlanUrl: string
}): CreatePropertyInput {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
- softLevels, floor, fitOut, parking, ceilingHeight, images,
+ softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
} = fields
const sf = {
@@ -66,6 +67,7 @@ export function buildCreatePropertyInput(fields: {
softFactors: sf,
hardFacts: hf,
images: images.length > 0 ? images : undefined,
+ floorPlanUrl: floorPlanUrl.trim() || undefined,
dataQuality: {
score: 1.0,
missingCriticalFields: [],
diff --git a/src/provider/MockupMatchProvider.ts b/src/provider/MockupMatchProvider.ts
index f116612..b92bf75 100644
--- a/src/provider/MockupMatchProvider.ts
+++ b/src/provider/MockupMatchProvider.ts
@@ -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()
+ 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) {
diff --git a/src/provider/MockupNeedProvider.ts b/src/provider/MockupNeedProvider.ts
index 9a8f519..d96ec42 100644
--- a/src/provider/MockupNeedProvider.ts
+++ b/src/provider/MockupNeedProvider.ts
@@ -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)
}
diff --git a/src/provider/MockupPipelineProvider.ts b/src/provider/MockupPipelineProvider.ts
index 4a55bb1..42f52b4 100644
--- a/src/provider/MockupPipelineProvider.ts
+++ b/src/provider/MockupPipelineProvider.ts
@@ -4,10 +4,22 @@ import { mockPipelineItems } from '../mock-data/pipelineItems'
const STORAGE_KEY = 'property_match_pipeline_items'
+const STAGE_MIGRATION: Record = {
+ 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]
}
diff --git a/src/services/matchSyncService.ts b/src/services/matchSyncService.ts
index b3fa011..9a23600 100644
--- a/src/services/matchSyncService.ts
+++ b/src/services/matchSyncService.ts
@@ -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
diff --git a/src/stores/inquiryStore.ts b/src/stores/inquiryStore.ts
index f904d14..82740be 100644
--- a/src/stores/inquiryStore.ts
+++ b/src/stores/inquiryStore.ts
@@ -9,6 +9,7 @@ export interface PendingInquiry {
matchScore: number
matchId?: string
propertyId?: string
+ pipelineItemId?: string
}
interface InquiryStore {