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:
Benjamin Sutter
2026-06-21 01:32:13 +02:00
parent 6206447dae
commit a8b54af0b8
13 changed files with 349 additions and 110 deletions
+49 -35
View File
@@ -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 => (
+12 -2
View File
@@ -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>