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:
95
src/app/api/events/route.ts
Normal file
95
src/app/api/events/route.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
import { addClient, removeClient, notifyOrganization, type NotificationEvent } from '@/lib/realtime'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let orgId: string | null = null
|
||||||
|
try {
|
||||||
|
const jwtSecret = process.env.JWT_SECRET
|
||||||
|
if (!jwtSecret) throw new Error('JWT_SECRET not configured')
|
||||||
|
const decoded = jwt.verify(token, jwtSecret) as {
|
||||||
|
userId?: string
|
||||||
|
phone?: string
|
||||||
|
adminId?: string
|
||||||
|
username?: string
|
||||||
|
type?: string
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only user tokens are supported for SSE for now
|
||||||
|
if (decoded.type === 'admin') {
|
||||||
|
return new Response('Admins not supported for SSE yet', { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// For user token, require organization id to route notifications
|
||||||
|
// Clients store it, but token doesn't include; we will not hit DB here to keep things simple.
|
||||||
|
// As a compromise, allow optionally passing orgId via query.
|
||||||
|
orgId = searchParams.get('orgId')
|
||||||
|
if (!orgId) {
|
||||||
|
// Fallback: if token was minted with organizationId claim
|
||||||
|
orgId = (decoded as any).organizationId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return new Response('Organization ID required', { status: 400 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return new Response('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const send = (evt: NotificationEvent) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = { orgId: orgId!, send }
|
||||||
|
addClient(orgId!, client)
|
||||||
|
|
||||||
|
// Initial hello
|
||||||
|
send({ type: 'connected', payload: { now: Date.now() } })
|
||||||
|
|
||||||
|
// Heartbeat to keep connection alive
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`:\n\n`))
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
removeClient(orgId!, client)
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on client disconnect
|
||||||
|
req.signal.addEventListener('abort', abortHandler)
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// no-op
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -23,12 +23,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { GET_CONVERSATIONS, GET_INCOMING_REQUESTS, GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
import { GET_CONVERSATIONS, GET_INCOMING_REQUESTS, GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
|
||||||
// Компонент для отображения логистических заявок (только для логистики)
|
// Компонент для отображения логистических заявок (только для логистики)
|
||||||
function LogisticsOrdersNotification() {
|
function LogisticsOrdersNotification() {
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
})
|
})
|
||||||
@ -46,8 +46,7 @@ function LogisticsOrdersNotification() {
|
|||||||
|
|
||||||
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
|
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
|
||||||
function FulfillmentSuppliesNotification() {
|
function FulfillmentSuppliesNotification() {
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
})
|
})
|
||||||
@ -65,8 +64,7 @@ function FulfillmentSuppliesNotification() {
|
|||||||
|
|
||||||
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
|
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
|
||||||
function WholesaleOrdersNotification() {
|
function WholesaleOrdersNotification() {
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
})
|
})
|
||||||
@ -95,21 +93,38 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
|
|||||||
const { isCollapsed, toggleSidebar } = useSidebar()
|
const { isCollapsed, toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
// Загружаем список чатов для подсчета непрочитанных сообщений
|
// Загружаем список чатов для подсчета непрочитанных сообщений
|
||||||
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
|
const { data: conversationsData, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
|
||||||
pollInterval: 60000, // Обновляем каждую минуту в сайдбаре - этого достаточно
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
||||||
notifyOnNetworkStatusChange: false, // Плавные обновления без мерцания
|
notifyOnNetworkStatusChange: false, // Плавные обновления без мерцания
|
||||||
})
|
})
|
||||||
|
|
||||||
// Загружаем входящие заявки для подсчета новых запросов
|
// Загружаем входящие заявки для подсчета новых запросов
|
||||||
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
|
||||||
pollInterval: 60000, // Обновляем каждую минуту
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
notifyOnNetworkStatusChange: false,
|
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 (
|
if (
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
|
@ -7,6 +7,7 @@ import React, { useState } from 'react'
|
|||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
// Импорты компонентов подразделов
|
// Импорты компонентов подразделов
|
||||||
import { FulfillmentConsumablesOrdersTab } from './fulfillment-supplies/fulfillment-consumables-orders-tab'
|
import { FulfillmentConsumablesOrdersTab } from './fulfillment-supplies/fulfillment-consumables-orders-tab'
|
||||||
@ -31,8 +32,7 @@ export function FulfillmentSuppliesDashboard() {
|
|||||||
const [activeThirdTab, setActiveThirdTab] = useState('new') // новые
|
const [activeThirdTab, setActiveThirdTab] = useState('new') // новые
|
||||||
|
|
||||||
// Загружаем данные о непринятых поставках
|
// Загружаем данные о непринятых поставках
|
||||||
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
onError: (error) => {
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (pendingError) {
|
if (pendingError) {
|
||||||
|
@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'
|
|||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
// Импорты компонентов подкатегорий
|
// Импорты компонентов подкатегорий
|
||||||
import { FulfillmentConsumablesOrdersTab } from './fulfillment-consumables-orders-tab'
|
import { FulfillmentConsumablesOrdersTab } from './fulfillment-consumables-orders-tab'
|
||||||
@ -33,8 +34,7 @@ export function FulfillmentSuppliesTab() {
|
|||||||
const [activeTab, setActiveTab] = useState('goods')
|
const [activeTab, setActiveTab] = useState('goods')
|
||||||
|
|
||||||
// Загружаем данные о непринятых поставках
|
// Загружаем данные о непринятых поставках
|
||||||
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
onError: (error) => {
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (pendingError) {
|
if (pendingError) {
|
||||||
|
@ -46,6 +46,7 @@ import {
|
|||||||
} from '@/graphql/queries'
|
} from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
import { WbReturnClaims } from './wb-return-claims'
|
import { WbReturnClaims } from './wb-return-claims'
|
||||||
|
|
||||||
@ -238,7 +239,26 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
refetch: refetchWarehouseStats,
|
refetch: refetchWarehouseStats,
|
||||||
} = useQuery(GET_FULFILLMENT_WAREHOUSE_STATS, {
|
} = useQuery(GET_FULFILLMENT_WAREHOUSE_STATS, {
|
||||||
fetchPolicy: 'no-cache', // Принудительно обходим кеш
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Логируем статистику склада для отладки
|
// Логируем статистику склада для отладки
|
||||||
|
@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { VoicePlayer } from '@/components/ui/voice-player'
|
import { VoicePlayer } from '@/components/ui/voice-player'
|
||||||
import { GET_MESSAGES } from '@/graphql/queries'
|
import { GET_MESSAGES } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: string
|
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 [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null)
|
||||||
|
|
||||||
// Загружаем все сообщения для получения вложений
|
// Загружаем все сообщения для получения вложений
|
||||||
const {
|
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||||
data: messagesData,
|
|
||||||
loading,
|
|
||||||
refetch,
|
|
||||||
} = useQuery(GET_MESSAGES, {
|
|
||||||
variables: { counterpartyId: counterparty.id, limit: 1000 },
|
variables: { counterpartyId: counterparty.id, limit: 1000 },
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-and-network',
|
||||||
pollInterval: 5000, // Обновляем каждые 5 секунд
|
|
||||||
notifyOnNetworkStatusChange: false, // Не показываем loading при обновлениях
|
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(() => {
|
useEffect(() => {
|
||||||
onViewChange?.()
|
onViewChange?.()
|
||||||
|
@ -24,6 +24,7 @@ import { GET_MESSAGES, GET_CONVERSATIONS } from '@/graphql/queries'
|
|||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
import { MessengerAttachments } from './messenger-attachments'
|
import { MessengerAttachments } from './messenger-attachments'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: string
|
id: string
|
||||||
@ -68,13 +69,8 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
|
|||||||
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
// Загружаем сообщения с контрагентом
|
// Загружаем сообщения с контрагентом
|
||||||
const {
|
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||||
data: messagesData,
|
|
||||||
loading,
|
|
||||||
refetch,
|
|
||||||
} = useQuery(GET_MESSAGES, {
|
|
||||||
variables: { counterpartyId: counterparty.id },
|
variables: { counterpartyId: counterparty.id },
|
||||||
pollInterval: 3000,
|
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-and-network',
|
||||||
errorPolicy: 'all',
|
errorPolicy: 'all',
|
||||||
})
|
})
|
||||||
@ -120,6 +116,27 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
|
|||||||
|
|
||||||
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
|
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(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
||||||
|
@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
import { MessengerChat } from './messenger-chat'
|
import { MessengerChat } from './messenger-chat'
|
||||||
import { MessengerConversations } from './messenger-conversations'
|
import { MessengerConversations } from './messenger-conversations'
|
||||||
@ -48,14 +49,19 @@ export function MessengerDashboard() {
|
|||||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||||
|
|
||||||
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
||||||
const {
|
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(
|
||||||
data: conversationsData,
|
GET_CONVERSATIONS,
|
||||||
loading: conversationsLoading,
|
{
|
||||||
refetch: refetchConversations,
|
|
||||||
} = useQuery(GET_CONVERSATIONS, {
|
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд - реже, но достаточно
|
|
||||||
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
|
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
|
||||||
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
|
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Realtime: обновление списка бесед
|
||||||
|
useRealtime({
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === 'message:new') refetchConversations()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
||||||
|
@ -7,6 +7,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
|
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
import { MarketCounterparties } from '../market/market-counterparties'
|
import { MarketCounterparties } from '../market/market-counterparties'
|
||||||
import { MarketFulfillment } from '../market/market-fulfillment'
|
import { MarketFulfillment } from '../market/market-fulfillment'
|
||||||
@ -20,12 +21,19 @@ export function PartnersDashboard() {
|
|||||||
const { getSidebarMargin } = useSidebar()
|
const { getSidebarMargin } = useSidebar()
|
||||||
|
|
||||||
// Загружаем входящие заявки для подсветки
|
// Загружаем входящие заявки для подсветки
|
||||||
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useRealtime({
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === 'counterparty:request:new' || evt.type === 'counterparty:request:updated') {
|
||||||
|
refetchIncoming()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const incomingRequests = incomingRequestsData?.incomingRequests || []
|
const incomingRequests = incomingRequestsData?.incomingRequests || []
|
||||||
const hasIncomingRequests = incomingRequests.length > 0
|
const hasIncomingRequests = incomingRequests.length > 0
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ import {
|
|||||||
} from '@/graphql/mutations'
|
} from '@/graphql/mutations'
|
||||||
import { GET_SUPPLY_ORDERS } from '@/graphql/queries'
|
import { GET_SUPPLY_ORDERS } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
// Типы для данных заказов
|
// Типы для данных заказов
|
||||||
interface SupplyOrderItem {
|
interface SupplyOrderItem {
|
||||||
@ -167,7 +168,15 @@ export function RealSupplyOrdersTab() {
|
|||||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-and-network',
|
||||||
notifyOnNetworkStatusChange: true,
|
notifyOnNetworkStatusChange: true,
|
||||||
pollInterval: 30000, // 🔔 Опрашиваем каждые 30 секунд для получения новых заказов
|
})
|
||||||
|
|
||||||
|
// Realtime: обновление списка заказов
|
||||||
|
useRealtime({
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Мутация для обновления статуса заказа
|
// Мутация для обновления статуса заказа
|
||||||
|
@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
|
|
||||||
import { AllSuppliesTab } from './fulfillment-supplies/all-supplies-tab'
|
import { AllSuppliesTab } from './fulfillment-supplies/all-supplies-tab'
|
||||||
import { RealSupplyOrdersTab } from './fulfillment-supplies/real-supply-orders-tab'
|
import { RealSupplyOrdersTab } from './fulfillment-supplies/real-supply-orders-tab'
|
||||||
@ -39,12 +40,19 @@ export function SuppliesDashboard() {
|
|||||||
const [statisticsData, setStatisticsData] = useState<any>(null)
|
const [statisticsData, setStatisticsData] = useState<any>(null)
|
||||||
|
|
||||||
// Загружаем счетчик поставок, требующих одобрения
|
// Загружаем счетчик поставок, требующих одобрения
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: 'ignore',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useRealtime({
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||||
|
refetchPending()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const pendingCount = pendingData?.pendingSuppliesCount
|
const pendingCount = pendingData?.pendingSuppliesCount
|
||||||
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
|
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
|
||||||
const hasPendingItems = (() => {
|
const hasPendingItems = (() => {
|
||||||
|
@ -4,6 +4,7 @@ import { GraphQLError, GraphQLScalarType, Kind } from 'graphql'
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { notifyMany, notifyOrganization } from '@/lib/realtime'
|
||||||
import { DaDataService } from '@/services/dadata-service'
|
import { DaDataService } from '@/services/dadata-service'
|
||||||
import { MarketplaceService } from '@/services/marketplace-service'
|
import { MarketplaceService } from '@/services/marketplace-service'
|
||||||
import { SmsService } from '@/services/sms-service'
|
import { SmsService } from '@/services/sms-service'
|
||||||
@ -3336,6 +3337,18 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Уведомляем получателя о новой заявке
|
||||||
|
try {
|
||||||
|
notifyOrganization(args.organizationId, {
|
||||||
|
type: 'counterparty:request:new',
|
||||||
|
payload: {
|
||||||
|
requestId: request.id,
|
||||||
|
senderId: request.senderId,
|
||||||
|
receiverId: request.receiverId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Заявка отправлена',
|
message: 'Заявка отправлена',
|
||||||
@ -3425,6 +3438,14 @@ export const resolvers = {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Оповещаем обе стороны об обновлении заявки и возможном изменении списка контрагентов
|
||||||
|
try {
|
||||||
|
notifyMany([request.senderId, request.receiverId], {
|
||||||
|
type: 'counterparty:request:updated',
|
||||||
|
payload: { requestId: updatedRequest.id, status: updatedRequest.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: args.accept ? 'Заявка принята' : 'Заявка отклонена',
|
message: args.accept ? 'Заявка принята' : 'Заявка отклонена',
|
||||||
@ -3597,6 +3618,19 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Реалтайм нотификация для обеих организаций (отправитель и получатель)
|
||||||
|
try {
|
||||||
|
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||||
|
type: 'message:new',
|
||||||
|
payload: {
|
||||||
|
messageId: message.id,
|
||||||
|
senderOrgId: message.senderOrganizationId,
|
||||||
|
receiverOrgId: message.receiverOrganizationId,
|
||||||
|
type: message.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Сообщение отправлено',
|
message: 'Сообщение отправлено',
|
||||||
@ -3684,6 +3718,18 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||||
|
type: 'message:new',
|
||||||
|
payload: {
|
||||||
|
messageId: message.id,
|
||||||
|
senderOrgId: message.senderOrganizationId,
|
||||||
|
receiverOrgId: message.receiverOrganizationId,
|
||||||
|
type: message.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Голосовое сообщение отправлено',
|
message: 'Голосовое сообщение отправлено',
|
||||||
@ -3765,6 +3811,18 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||||
|
type: 'message:new',
|
||||||
|
payload: {
|
||||||
|
messageId: message.id,
|
||||||
|
senderOrgId: message.senderOrganizationId,
|
||||||
|
receiverOrgId: message.receiverOrganizationId,
|
||||||
|
type: message.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Изображение отправлено',
|
message: 'Изображение отправлено',
|
||||||
@ -3846,6 +3904,18 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||||
|
type: 'message:new',
|
||||||
|
payload: {
|
||||||
|
messageId: message.id,
|
||||||
|
senderOrgId: message.senderOrganizationId,
|
||||||
|
receiverOrgId: message.receiverOrganizationId,
|
||||||
|
type: message.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Файл отправлен',
|
message: 'Файл отправлен',
|
||||||
@ -4225,6 +4295,14 @@ export const resolvers = {
|
|||||||
description: args.input.description,
|
description: args.input.description,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Реалтайм: уведомляем о смене складских остатков
|
||||||
|
try {
|
||||||
|
notifyOrganization(currentUser.organization.id, {
|
||||||
|
type: 'warehouse:changed',
|
||||||
|
payload: { supplyId: updatedSupply.id, change: -args.input.quantityUsed },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Использовано ${args.input.quantityUsed} ${updatedSupply.unit} расходника "${updatedSupply.name}"`,
|
message: `Использовано ${args.input.quantityUsed} ${updatedSupply.unit} расходника "${updatedSupply.name}"`,
|
||||||
@ -4492,6 +4570,20 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Реалтайм: уведомляем поставщика и вовлеченные стороны о новом заказе
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
currentUser.organization.id,
|
||||||
|
args.input.partnerId,
|
||||||
|
fulfillmentCenterId || undefined,
|
||||||
|
args.input.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:new',
|
||||||
|
payload: { id: supplyOrder.id, organizationId: currentUser.organization.id },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// 📦 РЕЗЕРВИРУЕМ ТОВАРЫ У ПОСТАВЩИКА
|
// 📦 РЕЗЕРВИРУЕМ ТОВАРЫ У ПОСТАВЩИКА
|
||||||
// Увеличиваем поле "ordered" для каждого заказанного товара
|
// Увеличиваем поле "ordered" для каждого заказанного товара
|
||||||
for (const item of args.input.items) {
|
for (const item of args.input.items) {
|
||||||
@ -6361,6 +6453,19 @@ export const resolvers = {
|
|||||||
console.warn('🎉 Склад организации успешно обновлен!')
|
console.warn('🎉 Склад организации успешно обновлен!')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Уведомляем вовлеченные организации об изменении статуса заказа
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
existingOrder.organizationId,
|
||||||
|
existingOrder.partnerId,
|
||||||
|
existingOrder.fulfillmentCenterId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Статус заказа поставки обновлен на "${args.status}"`,
|
message: `Статус заказа поставки обновлен на "${args.status}"`,
|
||||||
@ -6467,6 +6572,19 @@ export const resolvers = {
|
|||||||
newStatus: 'CONFIRMED',
|
newStatus: 'CONFIRMED',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
existingOrder.organizationId,
|
||||||
|
existingOrder.partnerId,
|
||||||
|
existingOrder.fulfillmentCenterId || undefined,
|
||||||
|
args.logisticsPartnerId,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Логистика успешно назначена',
|
message: 'Логистика успешно назначена',
|
||||||
@ -6590,6 +6708,19 @@ export const resolvers = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.warn(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`)
|
console.warn(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`)
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
updatedOrder.organizationId,
|
||||||
|
updatedOrder.partnerId,
|
||||||
|
updatedOrder.fulfillmentCenterId || undefined,
|
||||||
|
updatedOrder.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.',
|
message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.',
|
||||||
@ -6693,6 +6824,19 @@ export const resolvers = {
|
|||||||
updatedOrder.items.map((item) => `${item.productId}: -${item.quantity} шт.`).join(', '),
|
updatedOrder.items.map((item) => `${item.productId}: -${item.quantity} шт.`).join(', '),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
updatedOrder.organizationId,
|
||||||
|
updatedOrder.partnerId,
|
||||||
|
updatedOrder.fulfillmentCenterId || undefined,
|
||||||
|
updatedOrder.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: args.reason ? `Заказ отклонен поставщиком. Причина: ${args.reason}` : 'Заказ отклонен поставщиком',
|
message: args.reason ? `Заказ отклонен поставщиком. Причина: ${args.reason}` : 'Заказ отклонен поставщиком',
|
||||||
@ -6792,6 +6936,19 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
updatedOrder.organizationId,
|
||||||
|
updatedOrder.partnerId,
|
||||||
|
updatedOrder.fulfillmentCenterId || undefined,
|
||||||
|
updatedOrder.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
|
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
|
||||||
@ -6859,6 +7016,19 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
updatedOrder.organizationId,
|
||||||
|
updatedOrder.partnerId,
|
||||||
|
updatedOrder.fulfillmentCenterId || undefined,
|
||||||
|
updatedOrder.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Заказ подтвержден логистической компанией',
|
message: 'Заказ подтвержден логистической компанией',
|
||||||
@ -6926,6 +7096,19 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = [
|
||||||
|
updatedOrder.organizationId,
|
||||||
|
updatedOrder.partnerId,
|
||||||
|
updatedOrder.fulfillmentCenterId || undefined,
|
||||||
|
updatedOrder.logisticsPartnerId || undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
notifyMany(orgIds, {
|
||||||
|
type: 'supply-order:updated',
|
||||||
|
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: args.reason
|
message: args.reason
|
||||||
|
60
src/hooks/useRealtime.ts
Normal file
60
src/hooks/useRealtime.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { getAuthToken } from '@/lib/apollo-client'
|
||||||
|
|
||||||
|
export type RealtimeEvent = {
|
||||||
|
type: string
|
||||||
|
payload?: any
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
onEvent?: (evt: RealtimeEvent) => void
|
||||||
|
orgId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRealtime({ onEvent, orgId }: Options = {}) {
|
||||||
|
const handlerRef = useRef(onEvent)
|
||||||
|
handlerRef.current = onEvent
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const token = getAuthToken() || localStorage.getItem('adminAuthToken')
|
||||||
|
// Try to infer orgId from cached userData if not provided
|
||||||
|
let resolvedOrgId = orgId
|
||||||
|
if (!resolvedOrgId) {
|
||||||
|
try {
|
||||||
|
const userDataRaw = localStorage.getItem('userData')
|
||||||
|
if (userDataRaw) {
|
||||||
|
const user = JSON.parse(userDataRaw)
|
||||||
|
resolvedOrgId = user?.organization?.id
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !resolvedOrgId) return
|
||||||
|
|
||||||
|
const url = `/api/events?token=${encodeURIComponent(token)}&orgId=${encodeURIComponent(resolvedOrgId)}`
|
||||||
|
const es = new EventSource(url)
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
handlerRef.current?.(data)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
// Let the browser auto-reconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close()
|
||||||
|
}
|
||||||
|
}, [orgId])
|
||||||
|
}
|
||||||
|
|
67
src/lib/realtime.ts
Normal file
67
src/lib/realtime.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
|
export type NotificationEvent = {
|
||||||
|
type: string
|
||||||
|
payload?: unknown
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
orgId: string
|
||||||
|
send: (evt: NotificationEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RealtimeState = {
|
||||||
|
emitter: EventEmitter
|
||||||
|
clientsByOrg: Map<string, Set<Client>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure singleton across hot reloads
|
||||||
|
const g = globalThis as unknown as { __REALTIME__?: RealtimeState }
|
||||||
|
|
||||||
|
if (!g.__REALTIME__) {
|
||||||
|
g.__REALTIME__ = {
|
||||||
|
emitter: new EventEmitter(),
|
||||||
|
clientsByOrg: new Map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const realtime = g.__REALTIME__
|
||||||
|
|
||||||
|
export function addClient(orgId: string, client: Client) {
|
||||||
|
if (!realtime.clientsByOrg.has(orgId)) {
|
||||||
|
realtime.clientsByOrg.set(orgId, new Set())
|
||||||
|
}
|
||||||
|
realtime.clientsByOrg.get(orgId)!.add(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeClient(orgId: string, client: Client) {
|
||||||
|
const set = realtime.clientsByOrg.get(orgId)
|
||||||
|
if (set) {
|
||||||
|
set.delete(client)
|
||||||
|
if (set.size === 0) {
|
||||||
|
realtime.clientsByOrg.delete(orgId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyOrganization(orgId: string, event: NotificationEvent) {
|
||||||
|
const set = realtime.clientsByOrg.get(orgId)
|
||||||
|
if (!set) return
|
||||||
|
const payload: NotificationEvent = { ...event, createdAt: event.createdAt || new Date().toISOString() }
|
||||||
|
for (const client of set) {
|
||||||
|
try {
|
||||||
|
client.send(payload)
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore send errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyMany(orgIds: string[], event: NotificationEvent) {
|
||||||
|
const unique = Array.from(new Set(orgIds.filter(Boolean)))
|
||||||
|
for (const orgId of unique) {
|
||||||
|
notifyOrganization(orgId, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Reference in New Issue
Block a user