Добавлены новые поля в модель Supply и обновлены компоненты для работы с расходниками. Реализована логика загрузки и отображения чатов с непрочитанными сообщениями в мессенджере. Обновлены запросы и мутации GraphQL для поддержки новых полей. Исправлены ошибки отображения и добавлены индикаторы для непрочитанных сообщений.
This commit is contained in:
@ -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 = () => {
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user