feat(realtime): implement SSE realtime notifications; publish events from resolvers; remove polling in chat/sidebar/supplies/warehouse and wire realtime refetch

This commit is contained in:
Bivekich
2025-08-11 22:13:33 +03:00
parent 52107e793e
commit 3a56092385
14 changed files with 562 additions and 40 deletions

View File

@ -23,12 +23,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { GET_CONVERSATIONS, GET_INCOMING_REQUESTS, GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useRealtime } from '@/hooks/useRealtime'
import { useSidebar } from '@/hooks/useSidebar'
// Компонент для отображения логистических заявок (только для логистики)
function LogisticsOrdersNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
@ -46,8 +46,7 @@ function LogisticsOrdersNotification() {
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
function FulfillmentSuppliesNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
@ -65,8 +64,7 @@ function FulfillmentSuppliesNotification() {
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
function WholesaleOrdersNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
@ -95,21 +93,38 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
const { isCollapsed, toggleSidebar } = useSidebar()
// Загружаем список чатов для подсчета непрочитанных сообщений
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
pollInterval: 60000, // Обновляем каждую минуту в сайдбаре - этого достаточно
const { data: conversationsData, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
notifyOnNetworkStatusChange: false, // Плавные обновления без мерцания
})
// Загружаем входящие заявки для подсчета новых запросов
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
pollInterval: 60000, // Обновляем каждую минуту
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
notifyOnNetworkStatusChange: false,
})
// Реалтайм обновления бейджей
useRealtime({
onEvent: (evt) => {
switch (evt.type) {
case 'message:new':
refetchConversations()
break
case 'counterparty:request:new':
case 'counterparty:request:updated':
refetchIncoming()
break
case 'supply-order:new':
case 'supply-order:updated':
refetchPending()
break
}
},
})
// Если уже есть корневой сайдбар и это не корневой экземпляр — не рендерим дубликат
if (
typeof window !== 'undefined' &&

View File

@ -7,6 +7,7 @@ import React, { useState } from 'react'
import { Sidebar } from '@/components/dashboard/sidebar'
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
import { useSidebar } from '@/hooks/useSidebar'
import { useRealtime } from '@/hooks/useRealtime'
// Импорты компонентов подразделов
import { FulfillmentConsumablesOrdersTab } from './fulfillment-supplies/fulfillment-consumables-orders-tab'
@ -31,8 +32,7 @@ export function FulfillmentSuppliesDashboard() {
const [activeThirdTab, setActiveThirdTab] = useState('new') // новые
// Загружаем данные о непринятых поставках
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
onError: (error) => {
@ -40,6 +40,15 @@ export function FulfillmentSuppliesDashboard() {
},
})
// Realtime: обновление бейджа
useRealtime({
onEvent: (evt) => {
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
refetchPending()
}
},
})
// Логируем ошибку для диагностики
React.useEffect(() => {
if (pendingError) {

View File

@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
import { useRealtime } from '@/hooks/useRealtime'
// Импорты компонентов подкатегорий
import { FulfillmentConsumablesOrdersTab } from './fulfillment-consumables-orders-tab'
@ -33,8 +34,7 @@ export function FulfillmentSuppliesTab() {
const [activeTab, setActiveTab] = useState('goods')
// Загружаем данные о непринятых поставках
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
onError: (error) => {
@ -42,6 +42,15 @@ export function FulfillmentSuppliesTab() {
},
})
// Realtime: обновление счетчика
useRealtime({
onEvent: (evt) => {
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
refetchPending()
}
},
})
// Логируем ошибку для диагностики
React.useEffect(() => {
if (pendingError) {

View File

@ -46,6 +46,7 @@ import {
} from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
import { useRealtime } from '@/hooks/useRealtime'
import { WbReturnClaims } from './wb-return-claims'
@ -238,7 +239,26 @@ export function FulfillmentWarehouseDashboard() {
refetch: refetchWarehouseStats,
} = useQuery(GET_FULFILLMENT_WAREHOUSE_STATS, {
fetchPolicy: 'no-cache', // Принудительно обходим кеш
pollInterval: 60000, // Обновляем каждую минуту
})
// Real-time: обновляем ключевые блоки при событиях поставок/склада
useRealtime({
onEvent: (evt) => {
switch (evt.type) {
case 'supply-order:new':
case 'supply-order:updated':
refetchOrders()
refetchWarehouseStats()
refetchProducts()
refetchSellerSupplies()
refetchFulfillmentSupplies()
break
case 'warehouse:changed':
refetchWarehouseStats()
refetchFulfillmentSupplies()
break
}
},
})
// Логируем статистику склада для отладки

View File

@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { VoicePlayer } from '@/components/ui/voice-player'
import { GET_MESSAGES } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useRealtime } from '@/hooks/useRealtime'
interface Organization {
id: string
@ -50,17 +51,32 @@ export function MessengerAttachments({ counterparty, onViewChange }: MessengerAt
const [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null)
// Загружаем все сообщения для получения вложений
const {
data: messagesData,
loading,
refetch,
} = useQuery(GET_MESSAGES, {
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id, limit: 1000 },
fetchPolicy: 'cache-and-network',
pollInterval: 5000, // Обновляем каждые 5 секунд
notifyOnNetworkStatusChange: false, // Не показываем loading при обновлениях
})
// Реалтайм обновление вложений
useRealtime({
onEvent: (evt) => {
if (evt.type !== 'message:new') return
const { senderOrgId, receiverOrgId } = (evt.payload || {}) as {
senderOrgId?: string
receiverOrgId?: string
}
if (!user?.organization?.id) return
if (
senderOrgId === counterparty.id ||
receiverOrgId === counterparty.id ||
senderOrgId === user.organization.id ||
receiverOrgId === user.organization.id
) {
refetch()
}
},
})
// Обновляем данные при открытии вкладки вложений
useEffect(() => {
onViewChange?.()

View File

@ -24,6 +24,7 @@ import { GET_MESSAGES, GET_CONVERSATIONS } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { MessengerAttachments } from './messenger-attachments'
import { useRealtime } from '@/hooks/useRealtime'
interface Organization {
id: string
@ -68,13 +69,8 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
const messageInputRef = useRef<HTMLTextAreaElement>(null)
// Загружаем сообщения с контрагентом
const {
data: messagesData,
loading,
refetch,
} = useQuery(GET_MESSAGES, {
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id },
pollInterval: 3000,
fetchPolicy: 'cache-and-network',
errorPolicy: 'all',
})
@ -120,6 +116,27 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
// Реалтайм: обновляем чат при поступлении новых сообщений
useRealtime({
onEvent: (evt) => {
if (evt.type !== 'message:new') return
const { senderOrgId, receiverOrgId } = (evt.payload || {}) as {
senderOrgId?: string
receiverOrgId?: string
}
if (!user?.organization?.id) return
// Refetch if event is for this conversation
if (
senderOrgId === counterparty.id ||
receiverOrgId === counterparty.id ||
senderOrgId === user.organization.id ||
receiverOrgId === user.organization.id
) {
refetch()
}
},
})
// Отмечаем сообщения как прочитанные только если есть непрочитанные
useEffect(() => {
if (messages.length > 0 && user?.organization?.id && counterparty.id) {

View File

@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
import { useSidebar } from '@/hooks/useSidebar'
import { useRealtime } from '@/hooks/useRealtime'
import { MessengerChat } from './messenger-chat'
import { MessengerConversations } from './messenger-conversations'
@ -48,14 +49,19 @@ export function MessengerDashboard() {
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
const {
data: conversationsData,
loading: conversationsLoading,
refetch: refetchConversations,
} = useQuery(GET_CONVERSATIONS, {
pollInterval: 30000, // Обновляем каждые 30 секунд - реже, но достаточно
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(
GET_CONVERSATIONS,
{
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
},
)
// Realtime: обновление списка бесед
useRealtime({
onEvent: (evt) => {
if (evt.type === 'message:new') refetchConversations()
},
})
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались

View File

@ -7,6 +7,7 @@ import { Card } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
import { useSidebar } from '@/hooks/useSidebar'
import { useRealtime } from '@/hooks/useRealtime'
import { MarketCounterparties } from '../market/market-counterparties'
import { MarketFulfillment } from '../market/market-fulfillment'
@ -20,12 +21,19 @@ export function PartnersDashboard() {
const { getSidebarMargin } = useSidebar()
// Загружаем входящие заявки для подсветки
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
useRealtime({
onEvent: (evt) => {
if (evt.type === 'counterparty:request:new' || evt.type === 'counterparty:request:updated') {
refetchIncoming()
}
},
})
const incomingRequests = incomingRequestsData?.incomingRequests || []
const hasIncomingRequests = incomingRequests.length > 0

View File

@ -35,6 +35,7 @@ import {
} from '@/graphql/mutations'
import { GET_SUPPLY_ORDERS } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useRealtime } from '@/hooks/useRealtime'
// Типы для данных заказов
interface SupplyOrderItem {
@ -167,7 +168,15 @@ export function RealSupplyOrdersTab() {
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
pollInterval: 30000, // 🔔 Опрашиваем каждые 30 секунд для получения новых заказов
})
// Realtime: обновление списка заказов
useRealtime({
onEvent: (evt) => {
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
refetch()
}
},
})
// Мутация для обновления статуса заказа

View File

@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
import { useRealtime } from '@/hooks/useRealtime'
import { AllSuppliesTab } from './fulfillment-supplies/all-supplies-tab'
import { RealSupplyOrdersTab } from './fulfillment-supplies/real-supply-orders-tab'
@ -39,12 +40,19 @@ export function SuppliesDashboard() {
const [statisticsData, setStatisticsData] = useState<any>(null)
// Загружаем счетчик поставок, требующих одобрения
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
useRealtime({
onEvent: (evt) => {
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
refetchPending()
}
},
})
const pendingCount = pendingData?.pendingSuppliesCount
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
const hasPendingItems = (() => {