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
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
return stored === 'grid' ? 'grid' : 'list'
}
export function ActiveInquiriesTab() {
const { data: inquiries = [], isLoading } = useActiveInquiries()
interface Props {
filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
return (
<EmptyState
icon={<Inbox size={40} />}
title="Keine aktiven Anfragen"
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
title={emptyTitle ?? 'Keine aktiven Anfragen'}
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/>
)
}
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success')
reset()
}