diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..287ef52 --- /dev/null +++ b/src/app/api/events/route.ts @@ -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({ + 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', + }, + }) +} + diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 1db1c3c..4b7d6af 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -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' && diff --git a/src/components/fulfillment-supplies/fulfillment-supplies-dashboard.tsx b/src/components/fulfillment-supplies/fulfillment-supplies-dashboard.tsx index 1449f42..20b539d 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies-dashboard.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies-dashboard.tsx @@ -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) { diff --git a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx index 95aafe3..b6ba47e 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx @@ -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) { diff --git a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx index 5b37c3f..1715470 100644 --- a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx +++ b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx @@ -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 + } + }, }) // Логируем статистику склада для отладки diff --git a/src/components/messenger/messenger-attachments.tsx b/src/components/messenger/messenger-attachments.tsx index a8a16ee..b4454f1 100644 --- a/src/components/messenger/messenger-attachments.tsx +++ b/src/components/messenger/messenger-attachments.tsx @@ -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?.() diff --git a/src/components/messenger/messenger-chat.tsx b/src/components/messenger/messenger-chat.tsx index a6ae54b..3c26970 100644 --- a/src/components/messenger/messenger-chat.tsx +++ b/src/components/messenger/messenger-chat.tsx @@ -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(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) { diff --git a/src/components/messenger/messenger-dashboard.tsx b/src/components/messenger/messenger-dashboard.tsx index 63b8454..dc00b29 100644 --- a/src/components/messenger/messenger-dashboard.tsx +++ b/src/components/messenger/messenger-dashboard.tsx @@ -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(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() + }, }) // Также загружаем полный список контрагентов на случай, если с кем-то еще не общались diff --git a/src/components/partners/partners-dashboard.tsx b/src/components/partners/partners-dashboard.tsx index 8afff95..ed82f1c 100644 --- a/src/components/partners/partners-dashboard.tsx +++ b/src/components/partners/partners-dashboard.tsx @@ -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 diff --git a/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx b/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx index dd88be0..827c938 100644 --- a/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx +++ b/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx @@ -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() + } + }, }) // Мутация для обновления статуса заказа diff --git a/src/components/supplies/supplies-dashboard.tsx b/src/components/supplies/supplies-dashboard.tsx index 37f9fd9..1bfa561 100644 --- a/src/components/supplies/supplies-dashboard.tsx +++ b/src/components/supplies/supplies-dashboard.tsx @@ -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(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 = (() => { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 5f3a206..4bd804b 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -4,6 +4,7 @@ import { GraphQLError, GraphQLScalarType, Kind } from 'graphql' import jwt from 'jsonwebtoken' import { prisma } from '@/lib/prisma' +import { notifyMany, notifyOrganization } from '@/lib/realtime' import { DaDataService } from '@/services/dadata-service' import { MarketplaceService } from '@/services/marketplace-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 { success: true, 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 { success: true, 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 { success: true, 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 { success: true, 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 { success: true, 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 { success: true, message: 'Файл отправлен', @@ -4225,6 +4295,14 @@ export const resolvers = { description: args.input.description, }) + // Реалтайм: уведомляем о смене складских остатков + try { + notifyOrganization(currentUser.organization.id, { + type: 'warehouse:changed', + payload: { supplyId: updatedSupply.id, change: -args.input.quantityUsed }, + }) + } catch {} + return { success: true, 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" для каждого заказанного товара for (const item of args.input.items) { @@ -6361,6 +6453,19 @@ export const resolvers = { 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 { success: true, message: `Статус заказа поставки обновлен на "${args.status}"`, @@ -6467,6 +6572,19 @@ export const resolvers = { 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 { success: true, message: 'Логистика успешно назначена', @@ -6590,6 +6708,19 @@ export const resolvers = { }) 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 { success: true, message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.', @@ -6693,6 +6824,19 @@ export const resolvers = { 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 { success: true, 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 { success: true, 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 { success: true, 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 { success: true, message: args.reason diff --git a/src/hooks/useRealtime.ts b/src/hooks/useRealtime.ts new file mode 100644 index 0000000..1d7e19e --- /dev/null +++ b/src/hooks/useRealtime.ts @@ -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]) +} + diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts new file mode 100644 index 0000000..23c46d4 --- /dev/null +++ b/src/lib/realtime.ts @@ -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> +} + +// 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) + } +} +