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
@@ -6,7 +6,9 @@ import {
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { useCreateInquiry } from '../../hooks/useInquiries'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
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 currentUser = useSessionStore(s => s.currentUser)
const createInquiry = useCreateInquiry()
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
createInquiry.mutate({
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
propertyId: pendingInquiry.propertyId ?? 'unknown',
tenantName: currentUser?.name ?? 'Demand User',
tenantCompany: currentUser?.organizationName,
tenantEmail: currentUser?.email,
propertyAddress: pendingInquiry.propertyTitle,
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
message,
matchScore: pendingInquiry.matchScore,
})
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}