From 128d28af8d0400cff7dc7c17b2b2020de65522fa Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 18:00:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20in-memory=20inquiry=20flow=20=E2=80=94?= =?UTF-8?q?=20send=20from=20card,=20pipeline,=20and=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../match-card/IntelligenceMatchCard.tsx | 19 ++++ .../match-detail/InquiryQuickDialog.tsx | 87 ++++++++++++++++++ .../match-detail/NextActionsPanel.tsx | 88 ++++++++----------- src/components/pipeline/PipelineColumn.tsx | 10 ++- src/pages/demand/Anfragen.tsx | 10 ++- src/pages/demand/MatchDetail.tsx | 17 +++- src/pages/demand/Pipeline.tsx | 10 +++ src/pages/demand/Results.tsx | 2 + src/stores/inquiryStore.ts | 67 ++++++++++++++ 9 files changed, 252 insertions(+), 58 deletions(-) create mode 100644 src/components/match-detail/InquiryQuickDialog.tsx create mode 100644 src/stores/inquiryStore.ts diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 50ada5f..dbec8a2 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -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' @@ -25,6 +26,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro 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' @@ -141,6 +143,23 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro {/* Actions */} + {vm.actions.map(a => ( + + + + ) +} diff --git a/src/components/match-detail/NextActionsPanel.tsx b/src/components/match-detail/NextActionsPanel.tsx index ecdf6d5..fb4befe 100644 --- a/src/components/match-detail/NextActionsPanel.tsx +++ b/src/components/match-detail/NextActionsPanel.tsx @@ -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 = { HIGH: 0, MEDIUM: 1, LOW: 2 } -const PRIORITY_COLOR: Record = { - 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 Empfohlene Aktionen - {engineActions.length > 0 && ( - - {engineActions.map((action, i) => ( - - - {action.description && ( - - {action.description} - - )} - - ))} - - )} - - {/* Standard actions */} - {onShortlist && ( - + )} + + {engineActions.length > 0 && engineActions.map((action, i) => ( + + + {action.description && ( + + {action.description} + + )} + + ))} + + {onPipeline && ( + )} {onCompare && ( @@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe Zum Vergleich hinzufügen )} - {onReview && ( - - )} - {onReject && ( - - )} ) diff --git a/src/components/pipeline/PipelineColumn.tsx b/src/components/pipeline/PipelineColumn.tsx index b7387b4..1fe9ce0 100644 --- a/src/components/pipeline/PipelineColumn.tsx +++ b/src/components/pipeline/PipelineColumn.tsx @@ -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 && ( diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx index 54bd81e..485476a 100644 --- a/src/pages/demand/Anfragen.tsx +++ b/src/pages/demand/Anfragen.tsx @@ -6,6 +6,7 @@ import { } from '@mui/material' import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react' import { mockInquiries } from '../../mock-data/inquiries' +import { useInquiryStore } from '../../stores/inquiryStore' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline' import { useToastStore } from '../../stores/toastStore' import type { InquiryMessage } from '../../domain/inquiry' @@ -34,7 +35,10 @@ export default function Anfragen() { const preselectedId = searchParams.get('inquiry') + const storeInquiries = useInquiryStore(s => s.sentInquiries) const [inquiries, setInquiries] = useState(mockInquiries) + const allInquiries = [...storeInquiries, ...inquiries] + const [selectedId, setSelectedId] = useState( preselectedId ?? mockInquiries[0]?.id ?? null ) @@ -45,7 +49,7 @@ export default function Anfragen() { const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null) const threadRef = useRef(null) - const filtered = inquiries.filter(inq => { + const filtered = allInquiries.filter(inq => { const q = search.toLowerCase() const matchesSearch = !q || inq.tenantName.toLowerCase().includes(q) || @@ -55,8 +59,8 @@ export default function Anfragen() { 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 diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 1507de5..662638a 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -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 ?? '') @@ -126,6 +129,7 @@ export default function MatchDetail() { return ( + {/* Sticky back nav */} {/* ── Main content ── */} - + {/* ── Section A: Warum dieser Match? ── */} @@ -196,9 +200,14 @@ export default function MatchDetail() { {}} - onReject={() => {}} + onPipeline={handleShortlist} + onInquire={() => openInquiryDialog({ + propertyTitle: title, + location, + areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : undefined, + rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined, + matchScore: match.matchScore, + })} /> {/* ── Full analysis toggle ── */} diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index 4922db5..bf10684 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -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(null) const [activeId, setActiveId] = useState(null) const [overId, setOverId] = useState(null) @@ -66,6 +69,7 @@ export default function Pipeline() { return ( + {/* Header */} navigate(`/demand/anfragen?inquiry=${inquiryId}`)} + onCardChatClick={(item) => openInquiryDialog({ + propertyTitle: item.title, + location: item.location ?? '', + matchScore: item.matchScore ?? 0, + propertyId: item.propertyId, + })} isOver={isOver} /> diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index acf9983..cd3d1f3 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -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' @@ -101,6 +102,7 @@ export default function Results() { return ( + void + closeInquiryDialog: () => void + + // Sent inquiries (in-memory, persists for the session) + sentInquiries: Inquiry[] + addInquiry: (item: PendingInquiry, message: string) => void +} + +export const useInquiryStore = create((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: 'Wincasa AG', + 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] })) + }, +}))