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 ── */} - + + + {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 && ( + ) : ( + + )} + + )} ) } 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 {