feat(messages): unified conversation inbox — connect demand ↔ supply, offers reach the seeker
Phase 1 of the messaging rework: one conversation/thread model (Inquiry) is the single source of truth, read perspectivally by both sides. - domain/inquiry: add kind (INQUIRY|OFFER), tenantOrgId (demand org), offeredPropertyIds - provider/service: InquiryFilters gains tenantOrgId+kind; new createInquiry (demand→supply) & createOffer (supply→demand) materialize threads; addMessage bumps unread; reply carries senderType (tenant vs supply_user) - hooks: useCreateInquiry, useCreateOffer; reply payload carries sender perspective - demand: InquiryQuickDialog → createInquiry via provider (session-based tenant/org, no hardcode); Anfragen.tsx reads via React Query (tenantOrgId) with Gesendet/Erhalten tabs, replies via provider, shows offered properties for OFFER threads; inquiryStore reduced to dialog-only (removes Zustand server-data island) - supply: OfferChatComposer send now materializes an OFFER conversation into the seeker's inbox (routed via latentNeed.tenantOrgId); Anfragencenter gains a "Gesendet" tab; ActiveInquiriesTab filters by perspective (org + kind) - seed: merge demand inquiries with tenantOrgId=org-mobimo + 2 OFFER seeds; provider seeds from both sets Closes the loop: a sent inquiry reaches the Verwaltung and a sent offer reaches the Nachfrager — both answerable in one thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,9 @@ import {
|
||||
InputAdornment, Alert,
|
||||
} from '@mui/material'
|
||||
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||
@@ -18,10 +17,9 @@ import { useSessionStore } from '../../stores/sessionStore'
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'new', label: 'Neu' },
|
||||
{ key: 'in_progress', label: 'Aktiv' },
|
||||
{ key: 'answered', label: 'Beantwortet' },
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'INQUIRY', label: 'Gesendet' },
|
||||
{ key: 'OFFER', label: 'Erhalten' },
|
||||
]
|
||||
|
||||
// ── Anfragen page ─────────────────────────────────────────────────────────────
|
||||
@@ -36,20 +34,23 @@ 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]
|
||||
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
|
||||
const sendReply = useSendInquiryReply()
|
||||
const markRead = useMarkThreadAsRead()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [kindFilter, setKindFilter] = useState('all')
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
|
||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
||||
const threadRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Default-Auswahl: erste Konversation, sobald geladen
|
||||
useEffect(() => {
|
||||
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
|
||||
}, [allInquiries, selectedId])
|
||||
|
||||
const filtered = allInquiries.filter(inq => {
|
||||
const q = search.toLowerCase()
|
||||
const matchesSearch = !q ||
|
||||
@@ -59,12 +60,14 @@ export default function Anfragen() {
|
||||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
||||
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
|
||||
return matchesSearch && matchesKind
|
||||
})
|
||||
|
||||
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
|
||||
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
|
||||
|
||||
// Pipeline link for currently selected inquiry
|
||||
const linkedPipelineItem = selected?.propertyId
|
||||
@@ -86,7 +89,7 @@ export default function Anfragen() {
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
|
||||
markRead.mutate(id)
|
||||
setMobileShowChat(true)
|
||||
setKiAlert(null)
|
||||
}
|
||||
@@ -95,20 +98,10 @@ export default function Anfragen() {
|
||||
if (!replyText.trim() || !selectedId) return
|
||||
const text = replyText.trim()
|
||||
|
||||
const msg: InquiryMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
sendReply.mutate({
|
||||
inquiryId: selectedId,
|
||||
senderType: 'tenant',
|
||||
senderName: 'Sie',
|
||||
body: text,
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setInquiries(prev => prev.map(i =>
|
||||
i.id === selectedId
|
||||
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
|
||||
: i
|
||||
))
|
||||
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
|
||||
})
|
||||
setReplyText('')
|
||||
|
||||
// KI: detect stage transition from message content
|
||||
@@ -160,13 +153,13 @@ export default function Anfragen() {
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{FILTER_TABS.map(tab => (
|
||||
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
|
||||
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
|
||||
sx={{
|
||||
height: 22, fontSize: '0.7rem', cursor: 'pointer',
|
||||
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: statusFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: kindFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -291,6 +284,27 @@ export default function Anfragen() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Angebot: enthaltene Objekte */}
|
||||
{offeredProperties.length > 0 && (
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
|
||||
Enthaltene Objekte ({offeredProperties.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{offeredProperties.map(p => p && (
|
||||
<Chip
|
||||
key={p.id}
|
||||
icon={<Building2 size={12} />}
|
||||
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
|
||||
size="small"
|
||||
onClick={() => navigate(`/demand/property/${p.id}`)}
|
||||
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Thread */}
|
||||
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{selected.thread.map(msg => (
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
export default function Anfragencenter() {
|
||||
const location = useLocation()
|
||||
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
|
||||
}}
|
||||
>
|
||||
<Tab label="Aktive Anfragen" />
|
||||
<Tab label="Gesendet" />
|
||||
<Tab label="Latente Anfragen" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
{tab === 0 && <ActiveInquiriesTab />}
|
||||
{tab === 1 && <LatentInquiriesTab />}
|
||||
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
|
||||
{tab === 1 && (
|
||||
<ActiveInquiriesTab
|
||||
filters={{ organizationId: orgId, kind: 'OFFER' }}
|
||||
emptyTitle="Keine gesendeten Angebote"
|
||||
emptyDescription="Angebote, die Sie aus „Latente Anfragen" versenden, erscheinen hier."
|
||||
/>
|
||||
)}
|
||||
{tab === 2 && <LatentInquiriesTab />}
|
||||
</Box>
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user