diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da87c0d..36f9a8f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -191,6 +191,13 @@ model Supply { description String? price Decimal @db.Decimal(10, 2) quantity Int @default(0) + unit String @default("шт") + category String @default("Упаковка") + status String @default("planned") // planned, in-transit, delivered, in-stock + date DateTime @default(now()) + supplier String @default("Не указан") + minStock Int @default(0) + currentStock Int @default(0) imageUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 6992dd0..60ed55f 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { useRouter, usePathname } from 'next/navigation' +import { useQuery } from '@apollo/client' +import { GET_CONVERSATIONS } from '@/graphql/queries' import { Settings, LogOut, @@ -26,6 +28,16 @@ export function Sidebar() { const pathname = usePathname() const { isCollapsed, toggleSidebar } = useSidebar() + // Загружаем список чатов для подсчета непрочитанных сообщений + const { data: conversationsData } = useQuery(GET_CONVERSATIONS, { + pollInterval: 10000, // Обновляем каждые 10 секунд + fetchPolicy: 'cache-and-network', + errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар + }) + + const conversations = conversationsData?.conversations || [] + const totalUnreadCount = conversations.reduce((sum: number, conv: { unreadCount?: number }) => sum + (conv.unreadCount || 0), 0) + const getInitials = () => { const orgName = getOrganizationName() return orgName.charAt(0).toUpperCase() @@ -221,7 +233,7 @@ export function Sidebar() { + + + ) + } return (
@@ -297,28 +283,28 @@ export function MaterialsSuppliesTab() {
- {supply.category} + {supply.category || 'Не указано'} - {supply.quantity} {supply.unit} + {supply.quantity} {supply.unit || 'шт'}
- {supply.currentStock} {supply.unit} - {getStockStatusBadge(supply.currentStock, supply.minStock)} + {supply.currentStock || 0} {supply.unit || 'шт'} + {getStockStatusBadge(supply.currentStock || 0, supply.minStock || 0)}
- {supply.supplier} + {supply.supplier || 'Не указан'} - {formatDate(supply.date)} + {supply.date ? formatDate(supply.date) : 'Не указано'} - {formatCurrency(supply.amount)} + {formatCurrency(supply.price * supply.quantity)} - {getStatusBadge(supply.status)} + {getStatusBadge(supply.status || 'planned')} ))} diff --git a/src/components/messenger/messenger-chat.tsx b/src/components/messenger/messenger-chat.tsx index e312a27..0c2460e 100644 --- a/src/components/messenger/messenger-chat.tsx +++ b/src/components/messenger/messenger-chat.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useMemo } from 'react' import { useMutation, useQuery } from '@apollo/client' import { GET_MESSAGES } from '@/graphql/queries' -import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations' +import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE, MARK_MESSAGES_AS_READ } from '@/graphql/mutations' import { Button } from '@/components/ui/button' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' @@ -60,6 +60,13 @@ export function MessengerChat({ counterparty }: MessengerChatProps) { fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные errorPolicy: 'all' // Показываем данные даже при ошибках }) + + // Мутация для отметки сообщений как прочитанных + const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, { + onError: (error) => { + console.error('Ошибка отметки сообщений как прочитанных:', error) + } + }) const [sendMessageMutation] = useMutation(SEND_MESSAGE, { onCompleted: () => { @@ -99,6 +106,33 @@ export function MessengerChat({ counterparty }: MessengerChatProps) { const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages]) + // Автоматически отмечаем сообщения как прочитанные при открытии чата + useEffect(() => { + if (user?.organization?.id && counterparty.id) { + const conversationId = `${user.organization.id}-${counterparty.id}` + markMessagesAsReadMutation({ + variables: { conversationId }, + }) + } + }, [counterparty.id, user?.organization?.id, markMessagesAsReadMutation]) + + // Отмечаем сообщения как прочитанные при получении новых сообщений + useEffect(() => { + if (messages.length > 0 && user?.organization?.id && counterparty.id) { + const hasUnreadMessages = messages.some((msg: Message) => + !msg.isRead && + msg.senderOrganization?.id !== user.organization?.id + ) + + if (hasUnreadMessages) { + const conversationId = `${user.organization.id}-${counterparty.id}` + markMessagesAsReadMutation({ + variables: { conversationId }, + }) + } + } + }, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation]) + const scrollToBottom = () => { diff --git a/src/components/messenger/messenger-conversations.tsx b/src/components/messenger/messenger-conversations.tsx index d944b42..733459b 100644 --- a/src/components/messenger/messenger-conversations.tsx +++ b/src/components/messenger/messenger-conversations.tsx @@ -20,19 +20,38 @@ interface Organization { createdAt: string } +interface Conversation { + id: string + counterparty: Organization + lastMessage?: { + id: string + content?: string + type?: string + senderId: string + isRead: boolean + createdAt: string + } + unreadCount: number + updatedAt: string +} + interface MessengerConversationsProps { + conversations?: Conversation[] counterparties: Organization[] loading: boolean selectedCounterparty: string | null onSelectCounterparty: (counterpartyId: string) => void + onRefresh?: () => void compact?: boolean } export function MessengerConversations({ + conversations = [], counterparties, loading, selectedCounterparty, onSelectCounterparty, + onRefresh, compact = false }: MessengerConversationsProps) { const [searchTerm, setSearchTerm] = useState('') @@ -111,13 +130,39 @@ export function MessengerConversations({ } } - const filteredCounterparties = counterparties.filter(org => { + // Объединяем чаты и контрагентов, приоритет у чатов + const allCounterparties = new Map() + + // Сначала добавляем из чатов + conversations.forEach(conv => { + allCounterparties.set(conv.counterparty.id, { + org: conv.counterparty, + conversation: conv + }) + }) + + // Затем добавляем остальных контрагентов, если их еще нет + counterparties.forEach(org => { + if (!allCounterparties.has(org.id)) { + allCounterparties.set(org.id, { org }) + } + }) + + const filteredCounterparties = Array.from(allCounterparties.values()).filter(({ org }) => { if (!searchTerm) return true const name = getOrganizationName(org).toLowerCase() const managerName = getManagerName(org).toLowerCase() const inn = org.inn.toLowerCase() const search = searchTerm.toLowerCase() return name.includes(search) || inn.includes(search) || managerName.includes(search) + }).sort((a, b) => { + // Сортируем: сначала с активными чатами, потом по времени последнего сообщения + if (a.conversation && !b.conversation) return -1 + if (!a.conversation && b.conversation) return 1 + if (a.conversation && b.conversation) { + return new Date(b.conversation.updatedAt).getTime() - new Date(a.conversation.updatedAt).getTime() + } + return 0 }) if (loading) { @@ -136,7 +181,9 @@ export function MessengerConversations({

Контрагенты

-

{counterparties.length} активных

+

+ {conversations.length > 0 ? `${conversations.length} активных чатов` : `${counterparties.length} контрагентов`} +

)} @@ -145,7 +192,7 @@ export function MessengerConversations({ {compact && (
- {counterparties.length} + {allCounterparties.size}
)} @@ -172,11 +219,17 @@ export function MessengerConversations({

) : ( - filteredCounterparties.map((org) => ( + filteredCounterparties.map(({ org, conversation }) => (
onSelectCounterparty(org.id)} - className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 ${ + onClick={() => { + onSelectCounterparty(org.id) + // Если есть непрочитанные сообщения и функция обновления, вызываем её + if (conversation?.unreadCount && conversation.unreadCount > 0 && onRefresh) { + setTimeout(() => onRefresh(), 100) // Небольшая задержка для UI + } + }} + className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 relative ${ selectedCounterparty === org.id ? 'bg-white/20 border border-white/30' : 'bg-white/5 hover:bg-white/10 border border-white/10' @@ -184,7 +237,7 @@ export function MessengerConversations({ > {compact ? ( /* Компактный режим */ -
+
{org.users?.[0]?.avatar ? ( + {/* Индикатор непрочитанных сообщений для компактного режима */} + {conversation?.unreadCount && conversation.unreadCount > 0 && ( +
+ {conversation.unreadCount > 9 ? '9+' : conversation.unreadCount} +
+ )}
) : ( /* Обычный режим */
- - {org.users?.[0]?.avatar ? ( - - ) : null} - - {getInitials(org)} - - +
+ + {org.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(org)} + + + {/* Индикатор непрочитанных сообщений */} + {conversation?.unreadCount && conversation.unreadCount > 0 && ( +
+ {conversation.unreadCount > 9 ? '9+' : conversation.unreadCount} +
+ )} +
-

+

0 + ? 'text-white' + : 'text-white/80' + }`}> {getOrganizationName(org)}

- - {getTypeLabel(org.type)} - +
+ {conversation?.unreadCount && conversation.unreadCount > 0 && ( + + {conversation.unreadCount} + + )} + + {getTypeLabel(org.type)} + +
-

- {getShortCompanyName(org.fullName || '')} -

+ {conversation?.lastMessage ? ( +

0 + ? 'text-white/80' + : 'text-white/60' + }`}> + {conversation.lastMessage.type === 'TEXT' + ? conversation.lastMessage.content + : conversation.lastMessage.type === 'VOICE' + ? '🎵 Голосовое сообщение' + : conversation.lastMessage.type === 'IMAGE' + ? '🖼️ Изображение' + : conversation.lastMessage.type === 'FILE' + ? '📄 Файл' + : 'Сообщение' + } +

+ ) : ( +

+ {getShortCompanyName(org.fullName || '')} +

+ )}
)} diff --git a/src/components/messenger/messenger-dashboard.tsx b/src/components/messenger/messenger-dashboard.tsx index fa56874..33bf1b4 100644 --- a/src/components/messenger/messenger-dashboard.tsx +++ b/src/components/messenger/messenger-dashboard.tsx @@ -9,7 +9,7 @@ import { useSidebar } from '@/hooks/useSidebar' import { MessengerConversations } from './messenger-conversations' import { MessengerChat } from './messenger-chat' import { MessengerEmptyState } from './messenger-empty-state' -import { GET_MY_COUNTERPARTIES } from '@/graphql/queries' +import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries' import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' import { MessageCircle } from 'lucide-react' @@ -26,21 +26,47 @@ interface Organization { createdAt: string } +interface Conversation { + id: string + counterparty: Organization + lastMessage?: { + id: string + content?: string + type?: string + senderId: string + isRead: boolean + createdAt: string + } + unreadCount: number + updatedAt: string +} + export function MessengerDashboard() { const { getSidebarMargin } = useSidebar() const [selectedCounterparty, setSelectedCounterparty] = useState(null) + // Загружаем список чатов (conversations) для отображения непрочитанных сообщений + const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, { + pollInterval: 5000, // Обновляем каждые 5 секунд для получения новых непрочитанных сообщений + fetchPolicy: 'cache-and-network', + }) + + // Также загружаем полный список контрагентов на случай, если с кем-то еще не общались const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES) + + const conversations: Conversation[] = conversationsData?.conversations || [] const counterparties = counterpartiesData?.myCounterparties || [] const handleSelectCounterparty = (counterpartyId: string) => { setSelectedCounterparty(counterpartyId) } - const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty) + // Найти данные выбранного контрагента (сначала в чатах, потом в общем списке) + const selectedCounterpartyData = conversations.find((conv: Conversation) => conv.counterparty.id === selectedCounterparty)?.counterparty || + counterparties.find((cp: Organization) => cp.id === selectedCounterparty) // Если нет контрагентов, показываем заглушку - if (!counterpartiesLoading && counterparties.length === 0) { + if (!counterpartiesLoading && !conversationsLoading && counterparties.length === 0) { return (
@@ -74,10 +100,12 @@ export function MessengerDashboard() { > diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 412d5d5..011ecc6 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -603,6 +603,14 @@ export const CREATE_SUPPLY = gql` name description price + quantity + unit + category + status + date + supplier + minStock + currentStock imageUrl createdAt updatedAt @@ -621,6 +629,14 @@ export const UPDATE_SUPPLY = gql` name description price + quantity + unit + category + status + date + supplier + minStock + currentStock imageUrl createdAt updatedAt diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c6969b0..94c6f9c 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -72,6 +72,14 @@ export const GET_MY_SUPPLIES = gql` name description price + quantity + unit + category + status + date + supplier + minStock + currentStock imageUrl createdAt updatedAt diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 500bcd3..c82fa99 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -533,9 +533,81 @@ export const resolvers = { throw new GraphQLError("У пользователя нет организации"); } - // TODO: Здесь будет логика получения списка чатов - // Пока возвращаем пустой массив, так как таблица сообщений еще не создана - return []; + // Получаем всех контрагентов + const counterparties = await prisma.counterparty.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + counterparty: { + include: { + users: true, + }, + }, + }, + }); + + // Для каждого контрагента получаем последнее сообщение и количество непрочитанных + const conversations = await Promise.all( + counterparties.map(async (cp) => { + const counterpartyId = cp.counterparty.id; + + // Последнее сообщение с этим контрагентом + const lastMessage = await prisma.message.findFirst({ + where: { + OR: [ + { + senderOrganizationId: currentUser.organization!.id, + receiverOrganizationId: counterpartyId, + }, + { + senderOrganizationId: counterpartyId, + receiverOrganizationId: currentUser.organization!.id, + }, + ], + }, + include: { + sender: true, + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Количество непрочитанных сообщений от этого контрагента + const unreadCount = await prisma.message.count({ + where: { + senderOrganizationId: counterpartyId, + receiverOrganizationId: currentUser.organization!.id, + isRead: false, + }, + }); + + // Если есть сообщения с этим контрагентом, включаем его в список + if (lastMessage) { + return { + id: `${currentUser.organization!.id}-${counterpartyId}`, + counterparty: cp.counterparty, + lastMessage, + unreadCount, + updatedAt: lastMessage.createdAt, + }; + } + + return null; + }) + ); + + // Фильтруем null значения и сортируем по времени последнего сообщения + return conversations + .filter((conv) => conv !== null) + .sort((a, b) => new Date(b!.updatedAt).getTime() - new Date(a!.updatedAt).getTime()); }, // Мои услуги @@ -2410,8 +2482,34 @@ export const resolvers = { }); } - // TODO: Здесь будет логика обновления статуса сообщений - // Пока возвращаем успешный ответ + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }); + + if (!currentUser?.organization) { + throw new GraphQLError("У пользователя нет организации"); + } + + // conversationId имеет формат "currentOrgId-counterpartyId" + const [, counterpartyId] = args.conversationId.split('-'); + + if (!counterpartyId) { + throw new GraphQLError("Неверный ID беседы"); + } + + // Помечаем все непрочитанные сообщения от контрагента как прочитанные + await prisma.message.updateMany({ + where: { + senderOrganizationId: counterpartyId, + receiverOrganizationId: currentUser.organization.id, + isRead: false, + }, + data: { + isRead: true, + }, + }); + return true; }, @@ -2594,6 +2692,14 @@ export const resolvers = { name: string; description?: string; price: number; + quantity: number; + unit: string; + category: string; + status: string; + date: string; + supplier: string; + minStock: number; + currentStock: number; imageUrl?: string; }; }, @@ -2627,7 +2733,14 @@ export const resolvers = { name: args.input.name, description: args.input.description, price: args.input.price, - quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса + quantity: args.input.quantity, + unit: args.input.unit, + category: args.input.category, + status: args.input.status, + date: new Date(args.input.date), + supplier: args.input.supplier, + minStock: args.input.minStock, + currentStock: args.input.currentStock, imageUrl: args.input.imageUrl, organizationId: currentUser.organization.id, }, @@ -2657,6 +2770,14 @@ export const resolvers = { name: string; description?: string; price: number; + quantity: number; + unit: string; + category: string; + status: string; + date: string; + supplier: string; + minStock: number; + currentStock: number; imageUrl?: string; }; }, @@ -2696,7 +2817,14 @@ export const resolvers = { name: args.input.name, description: args.input.description, price: args.input.price, - quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса + quantity: args.input.quantity, + unit: args.input.unit, + category: args.input.category, + status: args.input.status, + date: new Date(args.input.date), + supplier: args.input.supplier, + minStock: args.input.minStock, + currentStock: args.input.currentStock, imageUrl: args.input.imageUrl, }, include: { organization: true }, @@ -2912,9 +3040,37 @@ export const resolvers = { }, }); + // Создаем расходники на основе заказанных товаров + const suppliesData = args.input.items.map((item) => { + const product = products.find((p) => p.id === item.productId)!; + const productWithCategory = supplyOrder.items.find( + (orderItem) => orderItem.productId === item.productId + )?.product; + + return { + name: product.name, + description: product.description || `Заказано у ${partner.name}`, + price: product.price, + quantity: item.quantity, + unit: "шт", + category: productWithCategory?.category?.name || "Упаковка", + status: "in-transit", // Статус "в пути" так как заказ только создан + date: new Date(args.input.deliveryDate), + supplier: partner.name || partner.fullName || "Не указан", + minStock: Math.round(item.quantity * 0.1), // 10% от заказанного как минимальный остаток + currentStock: 0, // Пока товар не пришел + organizationId: currentUser.organization!.id, + }; + }); + + // Создаем расходники + await prisma.supply.createMany({ + data: suppliesData, + }); + return { success: true, - message: "Заказ поставки создан успешно", + message: `Заказ поставки создан успешно! Добавлено ${suppliesData.length} расходников в каталог.`, order: supplyOrder, }; } catch (error) { @@ -3000,7 +3156,7 @@ export const resolvers = { weight: args.input.weight, dimensions: args.input.dimensions, material: args.input.material, - images: args.input.images || [], + images: JSON.stringify(args.input.images || []), mainImage: args.input.mainImage, isActive: args.input.isActive ?? true, organizationId: currentUser.organization.id, @@ -3111,7 +3267,7 @@ export const resolvers = { weight: args.input.weight, dimensions: args.input.dimensions, material: args.input.material, - images: args.input.images || [], + images: args.input.images ? JSON.stringify(args.input.images) : undefined, mainImage: args.input.mainImage, isActive: args.input.isActive ?? true, }, @@ -4213,6 +4369,25 @@ export const resolvers = { }, }, + Product: { + images: (parent: { images: unknown }) => { + // Если images это строка JSON, парсим её в массив + if (typeof parent.images === 'string') { + try { + return JSON.parse(parent.images); + } catch { + return []; + } + } + // Если это уже массив, возвращаем как есть + if (Array.isArray(parent.images)) { + return parent.images; + } + // Иначе возвращаем пустой массив + return []; + }, + }, + Message: { type: (parent: { type?: string | null }) => { return parent.type || "TEXT"; diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index a464a08..0829742 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -444,6 +444,14 @@ export const typeDefs = gql` name: String! description: String price: Float! + quantity: Int! + unit: String + category: String + status: String + date: DateTime! + supplier: String + minStock: Int + currentStock: Int imageUrl: String createdAt: DateTime! updatedAt: DateTime! @@ -454,6 +462,14 @@ export const typeDefs = gql` name: String! description: String price: Float! + quantity: Int! + unit: String! + category: String! + status: String! + date: DateTime! + supplier: String! + minStock: Int! + currentStock: Int! imageUrl: String }