Обновлены компоненты мессенджера: изменен интервал обновления списка чатов на 1 минуту и изменена политика выборки на 'cache-first' для повышения стабильности. В компоненте MessengerChat добавлена поддержка вложений и улучшена логика отметки сообщений как прочитанных с плавным обновлением. В компоненте MessengerConversations улучшено отображение загрузки и индикаторов непрочитанных сообщений. Также добавлен автофокус на поле ввода при открытии чата.
This commit is contained in:
@ -30,9 +30,10 @@ export function Sidebar() {
|
|||||||
|
|
||||||
// Загружаем список чатов для подсчета непрочитанных сообщений
|
// Загружаем список чатов для подсчета непрочитанных сообщений
|
||||||
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
|
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
|
||||||
pollInterval: 10000, // Обновляем каждые 10 секунд
|
pollInterval: 60000, // Обновляем каждую минуту в сайдбаре - этого достаточно
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-first',
|
||||||
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
||||||
|
notifyOnNetworkStatusChange: false, // Плавные обновления без мерцания
|
||||||
})
|
})
|
||||||
|
|
||||||
const conversations = conversationsData?.conversations || []
|
const conversations = conversationsData?.conversations || []
|
||||||
|
315
src/components/messenger/messenger-attachments.tsx
Normal file
315
src/components/messenger/messenger-attachments.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { GET_MESSAGES } from '@/graphql/queries'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Music,
|
||||||
|
Video,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
User
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
content?: string
|
||||||
|
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE'
|
||||||
|
voiceUrl?: string
|
||||||
|
voiceDuration?: number
|
||||||
|
fileUrl?: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string
|
||||||
|
senderId: string
|
||||||
|
senderOrganization: Organization
|
||||||
|
receiverOrganization: Organization
|
||||||
|
createdAt: string
|
||||||
|
isRead: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessengerAttachmentsProps {
|
||||||
|
counterparty: Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
|
||||||
|
// Загружаем все сообщения для получения вложений
|
||||||
|
const { data: messagesData, loading } = useQuery(GET_MESSAGES, {
|
||||||
|
variables: { counterpartyId: counterparty.id, limit: 1000 }, // Увеличиваем лимит для получения всех файлов
|
||||||
|
fetchPolicy: 'cache-first',
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages: Message[] = messagesData?.messages || []
|
||||||
|
|
||||||
|
// Фильтруем только сообщения с вложениями
|
||||||
|
const attachmentMessages = messages.filter(msg =>
|
||||||
|
msg.type && ['VOICE', 'IMAGE', 'FILE'].includes(msg.type) && msg.fileUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
// Группируем по типам
|
||||||
|
const imageMessages = attachmentMessages.filter(msg => msg.type === 'IMAGE')
|
||||||
|
const voiceMessages = attachmentMessages.filter(msg => msg.type === 'VOICE')
|
||||||
|
const fileMessages = attachmentMessages.filter(msg => msg.type === 'FILE')
|
||||||
|
|
||||||
|
const getOrganizationName = (org: Organization) => {
|
||||||
|
return org.name || org.fullName || 'Организация'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getManagerName = (org: Organization) => {
|
||||||
|
return org.users?.[0]?.managerName || 'Управляющий'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (org: Organization) => {
|
||||||
|
const name = getOrganizationName(org)
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (fileUrl: string, fileName: string) => {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = fileUrl
|
||||||
|
link.download = fileName
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderFileIcon = (fileType?: string) => {
|
||||||
|
if (!fileType) return <FileText className="h-4 w-4" />
|
||||||
|
|
||||||
|
if (fileType.startsWith('image/')) return <Image className="h-4 w-4" />
|
||||||
|
if (fileType.startsWith('audio/')) return <Music className="h-4 w-4" />
|
||||||
|
if (fileType.startsWith('video/')) return <Video className="h-4 w-4" />
|
||||||
|
return <FileText className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAttachmentCard = (message: Message) => {
|
||||||
|
const isCurrentUser = message.senderOrganization?.id === user?.organization?.id
|
||||||
|
const senderOrg = isCurrentUser ? user?.organization : message.senderOrganization
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={message.id} className="glass-card p-4 mb-3">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
{/* Аватар отправителя */}
|
||||||
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||||
|
{(senderOrg as Organization)?.users?.[0]?.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={(senderOrg as Organization).users![0].avatar!}
|
||||||
|
alt="Аватар отправителя"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||||||
|
{getInitials(senderOrg as Organization)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-white/80 text-sm font-medium">
|
||||||
|
{isCurrentUser ? 'Вы' : getOrganizationName(senderOrg as Organization)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{isCurrentUser ? 'Исходящий' : 'Входящий'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-white/50">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<span>{formatDate(message.createdAt)}</span>
|
||||||
|
<span>{formatTime(message.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Содержимое вложения */}
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-white/10 rounded-lg">
|
||||||
|
{message.type === 'IMAGE' ? (
|
||||||
|
<Image className="h-5 w-5 text-blue-400" />
|
||||||
|
) : message.type === 'VOICE' ? (
|
||||||
|
<Music className="h-5 w-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
renderFileIcon(message.fileType)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm font-medium">
|
||||||
|
{message.fileName ||
|
||||||
|
(message.type === 'VOICE' ? 'Голосовое сообщение' :
|
||||||
|
message.type === 'IMAGE' ? 'Изображение' : 'Файл')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-white/60">
|
||||||
|
{message.fileSize && (
|
||||||
|
<span>{formatFileSize(message.fileSize)}</span>
|
||||||
|
)}
|
||||||
|
{message.voiceDuration && (
|
||||||
|
<span>{Math.floor(message.voiceDuration / 60)}:{(message.voiceDuration % 60).toString().padStart(2, '0')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(message.fileUrl!, message.fileName || 'file')}
|
||||||
|
className="flex items-center justify-center w-8 h-8 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 text-white/70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-white/60">Загрузка вложений...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center space-x-3 mb-4 px-4 pt-4">
|
||||||
|
<FileText 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">
|
||||||
|
{attachmentMessages.length} файлов от {getOrganizationName(counterparty)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 px-4 pb-4">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 mb-4">
|
||||||
|
<TabsTrigger value="all" className="flex items-center space-x-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>Все ({attachmentMessages.length})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="images" className="flex items-center space-x-2">
|
||||||
|
<Image className="h-4 w-4" />
|
||||||
|
<span>Фото ({imageMessages.length})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="voice" className="flex items-center space-x-2">
|
||||||
|
<Music className="h-4 w-4" />
|
||||||
|
<span>Аудио ({voiceMessages.length})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="files" className="flex items-center space-x-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>Файлы ({fileMessages.length})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<TabsContent value="all" className="mt-0">
|
||||||
|
{attachmentMessages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">Нет вложений</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{attachmentMessages.map(renderAttachmentCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="images" className="mt-0">
|
||||||
|
{imageMessages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Image className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">Нет изображений</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{imageMessages.map(renderAttachmentCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="voice" className="mt-0">
|
||||||
|
{voiceMessages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Music className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">Нет аудио записей</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{voiceMessages.map(renderAttachmentCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="files" className="mt-0">
|
||||||
|
{fileMessages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">Нет файлов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{fileMessages.map(renderAttachmentCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
518
src/components/messenger/messenger-chat-with-attachments.tsx
Normal file
518
src/components/messenger/messenger-chat-with-attachments.tsx
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
|
import { GET_MESSAGES, GET_CONVERSATIONS } from '@/graphql/queries'
|
||||||
|
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'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
|
||||||
|
import { VoiceRecorder } from '@/components/ui/voice-recorder'
|
||||||
|
import { VoicePlayer } from '@/components/ui/voice-player'
|
||||||
|
import { FileUploader } from '@/components/ui/file-uploader'
|
||||||
|
import { ImageMessage } from '@/components/ui/image-message'
|
||||||
|
import { FileMessage } from '@/components/ui/file-message'
|
||||||
|
import { MessengerAttachments } from './messenger-attachments'
|
||||||
|
import { Send, FileText, MessageCircle } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
address?: string
|
||||||
|
phones?: Array<{ value: string }>
|
||||||
|
emails?: Array<{ value: string }>
|
||||||
|
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
content?: string
|
||||||
|
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE' | null
|
||||||
|
voiceUrl?: string
|
||||||
|
voiceDuration?: number
|
||||||
|
fileUrl?: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string
|
||||||
|
senderId: string
|
||||||
|
senderOrganization: Organization
|
||||||
|
receiverOrganization: Organization
|
||||||
|
createdAt: string
|
||||||
|
isRead: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessengerChatProps {
|
||||||
|
counterparty: Organization
|
||||||
|
onMessagesRead?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessengerChatWithAttachments({ counterparty, onMessagesRead }: MessengerChatProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [currentView, setCurrentView] = useState<'chat' | 'attachments'>('chat')
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Загружаем сообщения с контрагентом
|
||||||
|
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||||
|
variables: { counterpartyId: counterparty.id },
|
||||||
|
pollInterval: 3000,
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
errorPolicy: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мутация для отметки сообщений как прочитанных
|
||||||
|
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
|
||||||
|
onCompleted: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onMessagesRead?.()
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка отметки сообщений как прочитанных:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
|
||||||
|
|
||||||
|
// Отмечаем сообщения как прочитанные только если есть непрочитанные
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
||||||
|
const hasUnreadMessages = messages.some((msg: Message) =>
|
||||||
|
!msg.isRead &&
|
||||||
|
msg.senderOrganization?.id === counterparty.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasUnreadMessages) {
|
||||||
|
const conversationId = `${user.organization.id}-${counterparty.id}`
|
||||||
|
markMessagesAsReadMutation({
|
||||||
|
variables: { conversationId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Автофокус на поле ввода при открытии чата
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView === 'chat') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
messageInputRef.current?.focus()
|
||||||
|
}, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [counterparty.id, currentView])
|
||||||
|
|
||||||
|
const getOrganizationName = (org: Organization) => {
|
||||||
|
return org.name || org.fullName || 'Организация'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getShortCompanyName = (fullName: string) => {
|
||||||
|
if (!fullName) return 'Полное название не указано'
|
||||||
|
|
||||||
|
const legalFormReplacements: { [key: string]: string } = {
|
||||||
|
'Общество с ограниченной ответственностью': 'ООО',
|
||||||
|
'Открытое акционерное общество': 'ОАО',
|
||||||
|
'Закрытое акционерное общество': 'ЗАО',
|
||||||
|
'Публичное акционерное общество': 'ПАО',
|
||||||
|
'Непубличное акционерное общество': 'НАО',
|
||||||
|
'Акционерное общество': 'АО',
|
||||||
|
'Индивидуальный предприниматель': 'ИП',
|
||||||
|
'Товарищество с ограниченной ответственностью': 'ТОО',
|
||||||
|
'Частное предприятие': 'ЧП',
|
||||||
|
'Субъект предпринимательской деятельности': 'СПД'
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fullName
|
||||||
|
|
||||||
|
for (const [fullForm, shortForm] of Object.entries(legalFormReplacements)) {
|
||||||
|
const regex = new RegExp(`^${fullForm}\\s+`, 'i')
|
||||||
|
if (regex.test(result)) {
|
||||||
|
result = result.replace(regex, `${shortForm} `)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'Фулфилмент'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'Селлер'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'Логистика'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'Оптовик'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (org: Organization) => {
|
||||||
|
const name = getOrganizationName(org)
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!message.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
content: message.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setMessage('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки сообщения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
|
setMessage(prev => prev + emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendVoice = async (audioUrl: string, duration: number) => {
|
||||||
|
try {
|
||||||
|
await sendVoiceMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
voiceUrl: audioUrl,
|
||||||
|
voiceDuration: duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки голосового сообщения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendImage = async (fileUrl: string, fileName: string, fileSize: number, fileType: string) => {
|
||||||
|
try {
|
||||||
|
await sendImageMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
fileUrl,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки изображения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string) => {
|
||||||
|
try {
|
||||||
|
await sendFileMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
fileUrl,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки файла:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '00:00'
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Неизвестная дата'
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Сегодня'
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Вчера'
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Заголовок чата */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
{counterparty.users?.[0]?.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={counterparty.users[0].avatar}
|
||||||
|
alt="Аватар организации"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||||
|
{getInitials(counterparty)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">
|
||||||
|
{getOrganizationName(counterparty)}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
|
||||||
|
{getTypeLabel(counterparty.type)}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
{getShortCompanyName(counterparty.fullName || '')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Переключатель между чатом и вложениями */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant={currentView === 'chat' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView('chat')}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-1" />
|
||||||
|
Чат
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentView === 'attachments' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView('attachments')}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
|
Вложения
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основное содержимое */}
|
||||||
|
{currentView === 'chat' ? (
|
||||||
|
<>
|
||||||
|
{/* Сообщения */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="text-white/60">Загрузка сообщений...</div>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MessageCircle className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60 text-lg mb-2">Пока нет сообщений</p>
|
||||||
|
<p className="text-white/40 text-sm">
|
||||||
|
Начните беседу с {getOrganizationName(counterparty)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg: Message, index: number) => {
|
||||||
|
const isCurrentUser = msg.senderOrganization?.id === user?.organization?.id
|
||||||
|
const showDate = index === 0 ||
|
||||||
|
formatDate(messages[index - 1].createdAt) !== formatDate(msg.createdAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-white/10 rounded-full px-3 py-1">
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
{formatDate(msg.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
<div className={`max-w-xs lg:max-w-md xl:max-w-lg ${
|
||||||
|
isCurrentUser ? 'mr-2' : 'ml-2'
|
||||||
|
}`}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{msg.type === 'VOICE' && msg.voiceUrl ? (
|
||||||
|
<VoicePlayer
|
||||||
|
audioUrl={msg.voiceUrl}
|
||||||
|
duration={msg.voiceDuration || 0}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.type === 'IMAGE' && msg.fileUrl ? (
|
||||||
|
<ImageMessage
|
||||||
|
imageUrl={msg.fileUrl}
|
||||||
|
fileName={msg.fileName || 'image'}
|
||||||
|
fileSize={msg.fileSize}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.type === 'FILE' && msg.fileUrl ? (
|
||||||
|
<FileMessage
|
||||||
|
fileUrl={msg.fileUrl}
|
||||||
|
fileName={msg.fileName || 'file'}
|
||||||
|
fileSize={msg.fileSize}
|
||||||
|
fileType={msg.fileType}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.content ? (
|
||||||
|
<div className={`px-4 py-2 rounded-lg break-words ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-blue-500/20 text-white border border-blue-500/30'
|
||||||
|
: 'bg-white/10 text-white border border-white/20'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/70' : 'text-white/50'
|
||||||
|
}`}>
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поле ввода сообщения */}
|
||||||
|
<div className="px-4 py-3 border-t border-white/10">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<textarea
|
||||||
|
ref={messageInputRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Введите сообщение..."
|
||||||
|
className="glass-input text-white placeholder:text-white/40 w-full resize-none overflow-y-auto rounded-lg py-2 px-3"
|
||||||
|
rows={1}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
target.style.height = 'auto'
|
||||||
|
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
|
||||||
|
<VoiceRecorder onVoiceRecorded={handleSendVoice} />
|
||||||
|
<FileUploader
|
||||||
|
onFileUploaded={handleSendImage}
|
||||||
|
onFileUploadError={(error) => console.error('Ошибка загрузки файла:', error)}
|
||||||
|
accept="image/*"
|
||||||
|
icon="image"
|
||||||
|
/>
|
||||||
|
<FileUploader
|
||||||
|
onFileUploaded={handleSendFile}
|
||||||
|
onFileUploadError={(error) => console.error('Ошибка загрузки файла:', error)}
|
||||||
|
accept="*"
|
||||||
|
icon="file"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim()}
|
||||||
|
size="icon"
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Вложения */
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<MessengerAttachments counterparty={counterparty} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useMutation, useQuery } from '@apollo/client'
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
import { GET_MESSAGES } from '@/graphql/queries'
|
import { GET_MESSAGES, GET_CONVERSATIONS } from '@/graphql/queries'
|
||||||
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE, MARK_MESSAGES_AS_READ } 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 { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
@ -14,7 +14,8 @@ import { VoicePlayer } from '@/components/ui/voice-player'
|
|||||||
import { FileUploader } from '@/components/ui/file-uploader'
|
import { FileUploader } from '@/components/ui/file-uploader'
|
||||||
import { ImageMessage } from '@/components/ui/image-message'
|
import { ImageMessage } from '@/components/ui/image-message'
|
||||||
import { FileMessage } from '@/components/ui/file-message'
|
import { FileMessage } from '@/components/ui/file-message'
|
||||||
import { Send, MoreVertical } from 'lucide-react'
|
import { MessengerAttachments } from './messenger-attachments'
|
||||||
|
import { Send, MoreVertical, FileText, MessageCircle } from 'lucide-react'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -46,12 +47,15 @@ interface Message {
|
|||||||
|
|
||||||
interface MessengerChatProps {
|
interface MessengerChatProps {
|
||||||
counterparty: Organization
|
counterparty: Organization
|
||||||
|
onMessagesRead?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessengerChat({ counterparty }: MessengerChatProps) {
|
export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const [currentView, setCurrentView] = useState<'chat' | 'attachments'>('chat')
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
// Загружаем сообщения с контрагентом
|
// Загружаем сообщения с контрагентом
|
||||||
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||||
@ -63,6 +67,12 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
|||||||
|
|
||||||
// Мутация для отметки сообщений как прочитанных
|
// Мутация для отметки сообщений как прочитанных
|
||||||
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
|
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
|
||||||
|
onCompleted: () => {
|
||||||
|
// Деликатное обновление с задержкой чтобы было незаметно
|
||||||
|
setTimeout(() => {
|
||||||
|
onMessagesRead?.()
|
||||||
|
}, 500) // Полсекунды задержки для плавности
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Ошибка отметки сообщений как прочитанных:', error)
|
console.error('Ошибка отметки сообщений как прочитанных:', error)
|
||||||
}
|
}
|
||||||
@ -106,22 +116,12 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
|||||||
|
|
||||||
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
|
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(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
||||||
const hasUnreadMessages = messages.some((msg: Message) =>
|
const hasUnreadMessages = messages.some((msg: Message) =>
|
||||||
!msg.isRead &&
|
!msg.isRead &&
|
||||||
msg.senderOrganization?.id !== user.organization?.id
|
msg.senderOrganization?.id === counterparty.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasUnreadMessages) {
|
if (hasUnreadMessages) {
|
||||||
@ -143,6 +143,15 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// Автофокус на поле ввода при открытии чата
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
messageInputRef.current?.focus()
|
||||||
|
}, 100) // Небольшая задержка для корректного фокуса
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [counterparty.id])
|
||||||
|
|
||||||
const getOrganizationName = (org: Organization) => {
|
const getOrganizationName = (org: Organization) => {
|
||||||
return org.name || org.fullName || 'Организация'
|
return org.name || org.fullName || 'Организация'
|
||||||
}
|
}
|
||||||
@ -472,6 +481,7 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={messageInputRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
|
@ -165,7 +165,8 @@ export function MessengerConversations({
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
|
|
||||||
if (loading) {
|
// Показываем загрузку только при первоначальной загрузке, не при обновлениях
|
||||||
|
if (loading && (!conversations || conversations.length === 0) && (!counterparties || counterparties.length === 0)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<div className="text-white/60">Загрузка...</div>
|
<div className="text-white/60">Загрузка...</div>
|
||||||
@ -177,7 +178,8 @@ export function MessengerConversations({
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="flex items-center space-x-3 mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
<Users className="h-5 w-5 text-blue-400" />
|
<Users className="h-5 w-5 text-blue-400" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
||||||
@ -186,6 +188,8 @@ export function MessengerConversations({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Компактный заголовок */}
|
{/* Компактный заголовок */}
|
||||||
@ -222,13 +226,7 @@ export function MessengerConversations({
|
|||||||
filteredCounterparties.map(({ org, conversation }) => (
|
filteredCounterparties.map(({ org, conversation }) => (
|
||||||
<div
|
<div
|
||||||
key={org.id}
|
key={org.id}
|
||||||
onClick={() => {
|
onClick={() => onSelectCounterparty(org.id)}
|
||||||
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 ${
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 relative ${
|
||||||
selectedCounterparty === org.id
|
selectedCounterparty === org.id
|
||||||
? 'bg-white/20 border border-white/30'
|
? 'bg-white/20 border border-white/30'
|
||||||
@ -251,7 +249,7 @@ export function MessengerConversations({
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{/* Индикатор непрочитанных сообщений для компактного режима */}
|
{/* Индикатор непрочитанных сообщений для компактного режима */}
|
||||||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
{conversation && 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">
|
<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}
|
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||||||
</div>
|
</div>
|
||||||
@ -274,37 +272,31 @@ export function MessengerConversations({
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{/* Индикатор непрочитанных сообщений */}
|
{/* Индикатор непрочитанных сообщений */}
|
||||||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
{conversation && 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">
|
<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}
|
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<h4 className={`font-medium text-sm leading-tight truncate ${
|
<h4 className={`font-medium text-sm leading-tight truncate ${
|
||||||
conversation?.unreadCount && conversation.unreadCount > 0
|
conversation && conversation.unreadCount > 0
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
: 'text-white/80'
|
: 'text-white/80'
|
||||||
}`}>
|
}`}>
|
||||||
{getOrganizationName(org)}
|
{getOrganizationName(org)}
|
||||||
</h4>
|
</h4>
|
||||||
<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`}>
|
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0`}>
|
||||||
{getTypeLabel(org.type)}
|
{getTypeLabel(org.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{conversation?.lastMessage ? (
|
{conversation?.lastMessage ? (
|
||||||
<p className={`text-xs truncate ${
|
<p className={`text-xs truncate ${
|
||||||
conversation.unreadCount && conversation.unreadCount > 0
|
conversation && conversation.unreadCount > 0
|
||||||
? 'text-white/80'
|
? 'text-white/80'
|
||||||
: 'text-white/60'
|
: 'text-white/60'
|
||||||
}`}>
|
}`}>
|
||||||
|
@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
import { MessengerConversations } from './messenger-conversations'
|
import { MessengerConversations } from './messenger-conversations'
|
||||||
import { MessengerChat } from './messenger-chat'
|
import { MessengerChatWithAttachments } from './messenger-chat-with-attachments'
|
||||||
import { MessengerEmptyState } from './messenger-empty-state'
|
import { MessengerEmptyState } from './messenger-empty-state'
|
||||||
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||||
@ -47,8 +47,9 @@ export function MessengerDashboard() {
|
|||||||
|
|
||||||
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
||||||
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
|
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
|
||||||
pollInterval: 5000, // Обновляем каждые 5 секунд для получения новых непрочитанных сообщений
|
pollInterval: 30000, // Обновляем каждые 30 секунд - реже, но достаточно
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
|
||||||
|
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
|
||||||
})
|
})
|
||||||
|
|
||||||
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
||||||
@ -121,7 +122,10 @@ export function MessengerDashboard() {
|
|||||||
<Panel defaultSize={70} className="pl-2">
|
<Panel defaultSize={70} className="pl-2">
|
||||||
<Card className="glass-card h-full overflow-hidden">
|
<Card className="glass-card h-full overflow-hidden">
|
||||||
{selectedCounterparty && selectedCounterpartyData ? (
|
{selectedCounterparty && selectedCounterpartyData ? (
|
||||||
<MessengerChat counterparty={selectedCounterpartyData} />
|
<MessengerChatWithAttachments
|
||||||
|
counterparty={selectedCounterpartyData}
|
||||||
|
onMessagesRead={refetchConversations}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
Reference in New Issue
Block a user