Добавлены новые поля в модель Supply и обновлены компоненты для работы с расходниками. Реализована логика загрузки и отображения чатов с непрочитанными сообщениями в мессенджере. Обновлены запросы и мутации GraphQL для поддержки новых полей. Исправлены ошибки отображения и добавлены индикаторы для непрочитанных сообщений.

This commit is contained in:
Bivekich
2025-07-21 14:34:12 +03:00
parent f71262577a
commit a3fc7d969f
10 changed files with 514 additions and 125 deletions

View File

@ -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 = () => {

View File

@ -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<string, { org: Organization; conversation?: Conversation }>()
// Сначала добавляем из чатов
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({
<Users className="h-5 w-5 text-blue-400" />
<div>
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
<p className="text-white/60 text-sm">
{conversations.length > 0 ? `${conversations.length} активных чатов` : `${counterparties.length} контрагентов`}
</p>
</div>
</div>
)}
@ -145,7 +192,7 @@ export function MessengerConversations({
{compact && (
<div className="flex items-center justify-center mb-3">
<Users className="h-4 w-4 text-blue-400 mr-2" />
<span className="text-white font-medium text-sm">{counterparties.length}</span>
<span className="text-white font-medium text-sm">{allCounterparties.size}</span>
</div>
)}
@ -172,11 +219,17 @@ export function MessengerConversations({
</p>
</div>
) : (
filteredCounterparties.map((org) => (
filteredCounterparties.map(({ org, conversation }) => (
<div
key={org.id}
onClick={() => 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 ? (
/* Компактный режим */
<div className="flex items-center justify-center">
<div className="flex items-center justify-center relative">
<Avatar className="h-8 w-8">
{org.users?.[0]?.avatar ? (
<AvatarImage
@ -197,36 +250,80 @@ export function MessengerConversations({
{getInitials(org)}
</AvatarFallback>
</Avatar>
{/* Индикатор непрочитанных сообщений для компактного режима */}
{conversation?.unreadCount && conversation.unreadCount > 0 && (
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
</div>
)}
</div>
) : (
/* Обычный режим */
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
<div className="relative">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
{/* Индикатор непрочитанных сообщений */}
{conversation?.unreadCount && conversation.unreadCount > 0 && (
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="text-white font-medium text-sm leading-tight truncate">
<h4 className={`font-medium text-sm leading-tight truncate ${
conversation?.unreadCount && conversation.unreadCount > 0
? 'text-white'
: 'text-white/80'
}`}>
{getOrganizationName(org)}
</h4>
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0 ml-2`}>
{getTypeLabel(org.type)}
</Badge>
<div className="flex items-center space-x-2">
{conversation?.unreadCount && conversation.unreadCount > 0 && (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30 text-xs">
{conversation.unreadCount}
</Badge>
)}
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0`}>
{getTypeLabel(org.type)}
</Badge>
</div>
</div>
<p className="text-white/60 text-xs truncate">
{getShortCompanyName(org.fullName || '')}
</p>
{conversation?.lastMessage ? (
<p className={`text-xs truncate ${
conversation.unreadCount && conversation.unreadCount > 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'
? '📄 Файл'
: 'Сообщение'
}
</p>
) : (
<p className="text-white/60 text-xs truncate">
{getShortCompanyName(org.fullName || '')}
</p>
)}
</div>
</div>
)}

View File

@ -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<string | null>(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 (
<div className="h-screen flex overflow-hidden">
<Sidebar />
@ -74,10 +100,12 @@ export function MessengerDashboard() {
>
<Card className="glass-card h-full overflow-hidden p-4">
<MessengerConversations
conversations={conversations}
counterparties={counterparties}
loading={counterpartiesLoading}
loading={counterpartiesLoading || conversationsLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
onRefresh={refetchConversations}
compact={false}
/>
</Card>