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>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
@@ -25,6 +26,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
@@ -141,6 +143,23 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 { 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 [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)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +1,17 @@
|
|||||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
import type { Match, NextBestAction } from '../../domain/match'
|
import type { Match, NextBestAction } from '../../domain/match'
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
|
||||||
HIGH: 'contained',
|
|
||||||
MEDIUM: 'outlined',
|
|
||||||
LOW: 'outlined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
onCompare?: () => void
|
onCompare?: () => void
|
||||||
onShortlist?: () => void
|
onPipeline?: () => void
|
||||||
onReject?: () => void
|
onInquire?: () => void
|
||||||
onReview?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||||
)
|
)
|
||||||
@@ -26,19 +20,27 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||||
|
|
||||||
{engineActions.length > 0 && (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
{onInquire && (
|
||||||
{engineActions.map((action, i) => (
|
<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}>
|
<Box key={i}>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
variant={PRIORITY_COLOR[action.priority]}
|
variant="outlined"
|
||||||
sx={
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
action.priority === 'HIGH'
|
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
|
||||||
: { justifyContent: 'flex-start' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -49,14 +51,10 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Standard actions */}
|
{onPipeline && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||||
{onShortlist && (
|
Zur Pipeline hinzufügen
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
|
||||||
Zur Shortlist hinzufügen
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
Zum Vergleich hinzufügen
|
Zum Vergleich hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReview && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
|
||||||
Zur Überprüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
|
||||||
Match ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
|
onCardChatClick,
|
||||||
isOver,
|
isOver,
|
||||||
}: {
|
}: {
|
||||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (item: PipelineItem) => void
|
onSelect: (item: PipelineItem) => void
|
||||||
onChatClick: (inquiryId: string) => void
|
onChatClick: (inquiryId: string) => void
|
||||||
|
onCardChatClick?: (item: PipelineItem) => void
|
||||||
isOver: boolean
|
isOver: boolean
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={item.id === selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
onChatClick={
|
||||||
|
item.inquiryId
|
||||||
|
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
|
||||||
|
: onCardChatClick
|
||||||
|
? (e) => { e.stopPropagation(); onCardChatClick(item) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
||||||
import { mockInquiries } from '../../mock-data/inquiries'
|
import { mockInquiries } from '../../mock-data/inquiries'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
@@ -34,7 +35,10 @@ export default function Anfragen() {
|
|||||||
|
|
||||||
const preselectedId = searchParams.get('inquiry')
|
const preselectedId = searchParams.get('inquiry')
|
||||||
|
|
||||||
|
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||||
const [inquiries, setInquiries] = useState(mockInquiries)
|
const [inquiries, setInquiries] = useState(mockInquiries)
|
||||||
|
const allInquiries = [...storeInquiries, ...inquiries]
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
preselectedId ?? mockInquiries[0]?.id ?? null
|
preselectedId ?? mockInquiries[0]?.id ?? null
|
||||||
)
|
)
|
||||||
@@ -45,7 +49,7 @@ export default function Anfragen() {
|
|||||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
||||||
const threadRef = useRef<HTMLDivElement>(null)
|
const threadRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const filtered = inquiries.filter(inq => {
|
const filtered = allInquiries.filter(inq => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const matchesSearch = !q ||
|
const matchesSearch = !q ||
|
||||||
inq.tenantName.toLowerCase().includes(q) ||
|
inq.tenantName.toLowerCase().includes(q) ||
|
||||||
@@ -55,8 +59,8 @@ export default function Anfragen() {
|
|||||||
return matchesSearch && matchesStatus
|
return matchesSearch && matchesStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = inquiries.find(i => i.id === selectedId) ?? null
|
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||||
const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||||
|
|
||||||
// Pipeline link for currently selected inquiry
|
// Pipeline link for currently selected inquiry
|
||||||
const linkedPipelineItem = selected?.propertyId
|
const linkedPipelineItem = selected?.propertyId
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||||
import { PropertyMap } from '../../components/shared'
|
import { PropertyMap } from '../../components/shared'
|
||||||
@@ -46,6 +48,7 @@ export default function MatchDetail() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const { openSavedDialog } = usePipelineStore()
|
const { openSavedDialog } = usePipelineStore()
|
||||||
|
const { openInquiryDialog } = useInquiryStore()
|
||||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||||
|
|
||||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||||
@@ -126,6 +129,7 @@ export default function MatchDetail() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
|
|
||||||
{/* Sticky back nav */}
|
{/* Sticky back nav */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -164,7 +168,7 @@ export default function MatchDetail() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Main content ── */}
|
{/* ── Main content ── */}
|
||||||
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
|
||||||
{/* ── Section A: Warum dieser Match? ── */}
|
{/* ── Section A: Warum dieser Match? ── */}
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -196,9 +200,14 @@ export default function MatchDetail() {
|
|||||||
<NextActionsPanel
|
<NextActionsPanel
|
||||||
match={match}
|
match={match}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
onShortlist={handleShortlist}
|
onPipeline={handleShortlist}
|
||||||
onReview={() => {}}
|
onInquire={() => openInquiryDialog({
|
||||||
onReject={() => {}}
|
propertyTitle: title,
|
||||||
|
location,
|
||||||
|
areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : undefined,
|
||||||
|
rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Full analysis toggle ── */}
|
{/* ── Full analysis toggle ── */}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||||
import { TrendingUp } from 'lucide-react'
|
import { TrendingUp } from 'lucide-react'
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||||
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
||||||
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
||||||
@@ -22,6 +24,7 @@ export default function Pipeline() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: items = [] } = usePipelineItems()
|
const { data: items = [] } = usePipelineItems()
|
||||||
const { mutate: moveStage } = useMoveStage()
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const [overId, setOverId] = useState<string | null>(null)
|
const [overId, setOverId] = useState<string | null>(null)
|
||||||
@@ -66,6 +69,7 @@ export default function Pipeline() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -123,6 +127,12 @@ export default function Pipeline() {
|
|||||||
selectedId={syncedSelected?.id ?? null}
|
selectedId={syncedSelected?.id ?? null}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
|
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
|
||||||
|
onCardChatClick={(item) => openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.location ?? '',
|
||||||
|
matchScore: item.matchScore ?? 0,
|
||||||
|
propertyId: item.propertyId,
|
||||||
|
})}
|
||||||
isOver={isOver}
|
isOver={isOver}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UnifiedResultFeed,
|
UnifiedResultFeed,
|
||||||
} from '../../components/results'
|
} from '../../components/results'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import { ErrorState } from '../../components/ui'
|
import { ErrorState } from '../../components/ui'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -101,6 +102,7 @@ export default function Results() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
<InquiryQuickDialog />
|
||||||
<ResultFeedHeader
|
<ResultFeedHeader
|
||||||
total={sorted.length}
|
total={sorted.length}
|
||||||
platformCount={platformCount}
|
platformCount={platformCount}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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] }))
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user