Обновлен компонент MessengerAttachments: добавлена поддержка голосовых сообщений с использованием VoicePlayer, улучшена логика фильтрации вложений, добавлены функции обновления данных при открытии вкладки вложений. Удален устаревший компонент MessengerChatWithAttachments и обновлен интерфейс для улучшения взаимодействия с пользователем.

This commit is contained in:
Bivekich
2025-07-21 15:11:52 +03:00
parent b935807cc2
commit 8063ca64b8
4 changed files with 393 additions and 893 deletions

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { GET_MESSAGES } from '@/graphql/queries' import { GET_MESSAGES } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { ImageLightbox } from '@/components/ui/image-lightbox' import { ImageLightbox } from '@/components/ui/image-lightbox'
import { VoicePlayer } from '@/components/ui/voice-player'
import { import {
FileText, FileText,
Image, Image,
@ -16,7 +17,8 @@ import {
Video, Video,
Download, Download,
Calendar, Calendar,
User User,
RefreshCw
} from 'lucide-react' } from 'lucide-react'
interface Organization { interface Organization {
@ -47,24 +49,33 @@ interface Message {
interface MessengerAttachmentsProps { interface MessengerAttachmentsProps {
counterparty: Organization counterparty: Organization
onViewChange?: () => void
} }
export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps) { export function MessengerAttachments({ counterparty, onViewChange }: MessengerAttachmentsProps) {
const { user } = useAuth() const { user } = useAuth()
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
const [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null) const [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null)
// Загружаем все сообщения для получения вложений // Загружаем все сообщения для получения вложений
const { data: messagesData, loading } = useQuery(GET_MESSAGES, { const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id, limit: 1000 }, // Увеличиваем лимит для получения всех файлов variables: { counterpartyId: counterparty.id, limit: 1000 },
fetchPolicy: 'cache-first', fetchPolicy: 'cache-and-network',
pollInterval: 5000, // Обновляем каждые 5 секунд
notifyOnNetworkStatusChange: false, // Не показываем loading при обновлениях
}) })
// Обновляем данные при открытии вкладки вложений
useEffect(() => {
onViewChange?.()
}, [onViewChange])
const messages: Message[] = messagesData?.messages || [] const messages: Message[] = messagesData?.messages || []
// Фильтруем только сообщения с вложениями // Фильтруем только сообщения с вложениями
const attachmentMessages = messages.filter(msg => const attachmentMessages = messages.filter(msg =>
msg.type && ['VOICE', 'IMAGE', 'FILE'].includes(msg.type) && msg.fileUrl msg.type && ['VOICE', 'IMAGE', 'FILE'].includes(msg.type) &&
(msg.fileUrl || (msg.type === 'VOICE' && msg.voiceUrl))
) )
// Группируем по типам // Группируем по типам
@ -76,10 +87,6 @@ export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps
return org.name || org.fullName || 'Организация' return org.name || org.fullName || 'Организация'
} }
const getManagerName = (org: Organization) => {
return org.users?.[0]?.managerName || 'Управляющий'
}
const getInitials = (org: Organization) => { const getInitials = (org: Organization) => {
const name = getOrganizationName(org) const name = getOrganizationName(org)
return name.charAt(0).toUpperCase() return name.charAt(0).toUpperCase()
@ -128,12 +135,12 @@ export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps
} }
const renderFileIcon = (fileType?: string) => { const renderFileIcon = (fileType?: string) => {
if (!fileType) return <FileText className="h-4 w-4" /> if (!fileType) return <FileText className="h-4 w-4 text-gray-400" />
if (fileType.startsWith('image/')) return <Image className="h-4 w-4" /> if (fileType.startsWith('image/')) return <Image className="h-4 w-4 text-blue-400" />
if (fileType.startsWith('audio/')) return <Music className="h-4 w-4" /> if (fileType.startsWith('audio/')) return <Music className="h-4 w-4 text-green-400" />
if (fileType.startsWith('video/')) return <Video className="h-4 w-4" /> if (fileType.startsWith('video/')) return <Video className="h-4 w-4 text-purple-400" />
return <FileText className="h-4 w-4" /> return <FileText className="h-4 w-4 text-gray-400" />
} }
const renderAttachmentCard = (message: Message) => { const renderAttachmentCard = (message: Message) => {
@ -145,13 +152,13 @@ export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
{/* Аватар отправителя */} {/* Аватар отправителя */}
<Avatar className="h-8 w-8 flex-shrink-0"> <Avatar className="h-8 w-8 flex-shrink-0">
{(senderOrg as Organization)?.users?.[0]?.avatar ? ( {(senderOrg as Organization)?.users?.[0]?.avatar ? (
<AvatarImage <AvatarImage
src={(senderOrg as Organization).users![0].avatar!} src={(senderOrg as Organization).users![0].avatar!}
alt="Аватар отправителя" alt="Аватар отправителя"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : null} ) : null}
<AvatarFallback className="bg-purple-500 text-white text-xs"> <AvatarFallback className="bg-purple-500 text-white text-xs">
{getInitials(senderOrg as Organization)} {getInitials(senderOrg as Organization)}
</AvatarFallback> </AvatarFallback>
@ -175,61 +182,65 @@ export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps
</div> </div>
{/* Содержимое вложения */} {/* Содержимое вложения */}
<div className="flex items-center justify-between bg-white/5 rounded-lg p-3"> {message.type === 'VOICE' && message.voiceUrl ? (
<div className="flex items-center space-x-3 flex-1 min-w-0"> /* Голосовой плеер */
{/* Превью изображения или иконка */} <div className="w-full">
{message.type === 'IMAGE' && message.fileUrl ? ( <VoicePlayer
<div audioUrl={message.voiceUrl}
className="relative w-12 h-12 rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity" duration={message.voiceDuration || 0}
onClick={() => handleImageClick(message.fileUrl!, message.fileName, message.fileSize)} isCurrentUser={isCurrentUser}
> />
<img </div>
src={message.fileUrl} ) : (
alt={message.fileName || зображение'} /* Обычные файлы и изображения */
className="w-full h-full object-cover" <div className="flex items-center justify-between bg-white/5 rounded-lg p-3">
/> <div className="flex items-center space-x-3 flex-1 min-w-0">
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity"> {/* Превью изображения или иконка */}
<Image className="h-4 w-4 text-white" /> {message.type === 'IMAGE' && message.fileUrl ? (
<div
className="relative w-12 h-12 rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(message.fileUrl!, message.fileName, message.fileSize)}
>
<img
src={message.fileUrl}
alt={message.fileName || 'Изображение'}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<Image className="h-4 w-4 text-white" />
</div>
</div>
) : (
<div className="flex items-center justify-center w-10 h-10 bg-white/10 rounded-lg flex-shrink-0">
{renderFileIcon(message.fileType)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{message.fileName ||
(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.type === 'IMAGE' && (
<span className="text-blue-300"> Нажмите для просмотра</span>
)}
</div> </div>
</div> </div>
) : (
<div className="flex items-center justify-center w-10 h-10 bg-white/10 rounded-lg flex-shrink-0">
{message.type === 'VOICE' ? (
<Music className="h-5 w-5 text-green-400" />
) : (
renderFileIcon(message.fileType)
)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{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>
)}
{message.type === 'IMAGE' && (
<span className="text-blue-300"> Нажмите для просмотра</span>
)}
</div>
</div> </div>
</div>
<button <button
onClick={() => handleDownload(message.fileUrl!, message.fileName || 'file')} 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 flex-shrink-0 ml-2" className="flex items-center justify-center w-8 h-8 bg-white/10 hover:bg-white/20 rounded-lg transition-colors flex-shrink-0 ml-2"
title="Скачать файл" title="Скачать файл"
> >
<Download className="h-4 w-4 text-white/70" /> <Download className="h-4 w-4 text-white/70" />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
</Card> </Card>
@ -244,131 +255,141 @@ export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps
) )
} }
return ( if (attachmentMessages.length === 0) {
<div className="h-full flex flex-col"> return (
<div className="flex items-center space-x-3 mb-4 px-4 pt-4"> <div className="flex items-center justify-center h-64">
<FileText className="h-5 w-5 text-blue-400" /> <div className="text-center">
<div> <FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white">Вложения</h3> <p className="text-white/60 text-lg mb-2">Нет вложений</p>
<p className="text-white/60 text-sm"> <p className="text-white/40 text-sm">
{attachmentMessages.length} файлов от {getOrganizationName(counterparty)} Файлы, изображения и голосовые сообщения будут отображаться здесь
</p> </p>
</div> </div>
</div> </div>
)
}
<div className="flex-1 px-4 pb-4"> return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full"> <div className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-4 mb-4"> <div className="flex-1 overflow-auto p-4">
<TabsTrigger value="all" className="flex items-center space-x-2"> {/* Кнопка обновления */}
<FileText className="h-4 w-4" /> <div className="flex justify-between items-center mb-4">
<span>Все ({attachmentMessages.length})</span> <h2 className="text-white/80 text-lg font-medium">Вложения</h2>
<button
onClick={() => refetch()}
className="flex items-center space-x-2 px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
title="Обновить вложения"
>
<RefreshCw className="h-4 w-4 text-white/70" />
<span className="text-white/70 text-sm">Обновить</span>
</button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="all" className="text-xs">
Все ({attachmentMessages.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="images" className="flex items-center space-x-2"> <TabsTrigger value="images" className="text-xs">
<Image className="h-4 w-4" /> Фото ({imageMessages.length})
<span>Фото ({imageMessages.length})</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="voice" className="flex items-center space-x-2"> <TabsTrigger value="audio" className="text-xs">
<Music className="h-4 w-4" /> Аудио ({voiceMessages.length})
<span>Аудио ({voiceMessages.length})</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="files" className="flex items-center space-x-2"> <TabsTrigger value="files" className="text-xs">
<FileText className="h-4 w-4" /> Файлы ({fileMessages.length})
<span>Файлы ({fileMessages.length})</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<div className="h-full overflow-auto"> <TabsContent value="all" className="mt-0">
<TabsContent value="all" className="mt-0"> {attachmentMessages.length === 0 ? (
{attachmentMessages.length === 0 ? ( <div className="flex items-center justify-center h-64">
<div className="flex items-center justify-center h-64"> <div className="text-center">
<div className="text-center"> <FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" /> <p className="text-white/60">Нет вложений</p>
<p className="text-white/60">Нет вложений</p>
</div>
</div> </div>
) : ( </div>
<div> ) : (
{attachmentMessages.map(renderAttachmentCard)} <div>
</div> {attachmentMessages.map(renderAttachmentCard)}
)} </div>
</TabsContent> )}
</TabsContent>
<TabsContent value="images" className="mt-0"> <TabsContent value="images" className="mt-0">
{imageMessages.length === 0 ? ( {imageMessages.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<Image className="h-12 w-12 text-white/20 mx-auto mb-4" /> <Image className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">Нет изображений</p> <p className="text-white/60">Нет изображений</p>
</div>
</div> </div>
) : ( </div>
<div> ) : (
{/* Сетка изображений для быстрого просмотра */} <div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-6"> {/* Сетка изображений для быстрого просмотра */}
{imageMessages.map((msg) => ( <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-6">
<div {imageMessages.map((msg) => (
key={`grid-${msg.id}`} <div
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity group" key={`grid-${msg.id}`}
onClick={() => handleImageClick(msg.fileUrl!, msg.fileName, msg.fileSize)} className="relative aspect-square rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity group"
> onClick={() => handleImageClick(msg.fileUrl!, msg.fileName, msg.fileSize)}
<img >
src={msg.fileUrl!} <img
alt={msg.fileName || 'Изображение'} src={msg.fileUrl!}
className="w-full h-full object-cover" alt={msg.fileName || 'Изображение'}
/> className="w-full h-full object-cover"
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center"> />
<Image className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
</div> <Image className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute bottom-2 left-2 right-2"> </div>
<div className="text-white text-xs truncate bg-black/50 px-2 py-1 rounded"> <div className="absolute bottom-2 left-2 right-2">
{formatDate(msg.createdAt)} <div className="text-white text-xs truncate bg-black/50 px-2 py-1 rounded">
</div> {formatDate(msg.createdAt)}
</div> </div>
</div> </div>
))} </div>
</div> ))}
</div>
{/* Детальный список карточек */} {/* Детальный список карточек */}
<div className="border-t border-white/10 pt-4"> <div className="border-t border-white/10 pt-4">
<h4 className="text-white/80 text-sm font-medium mb-3">Детальная информация</h4> <h4 className="text-white/80 text-sm font-medium mb-3">Детальная информация</h4>
{imageMessages.map(renderAttachmentCard)} {imageMessages.map(renderAttachmentCard)}
</div>
</div> </div>
)} </div>
</TabsContent> )}
</TabsContent>
<TabsContent value="voice" className="mt-0"> <TabsContent value="audio" className="mt-0">
{voiceMessages.length === 0 ? ( {voiceMessages.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<Music className="h-12 w-12 text-white/20 mx-auto mb-4" /> <Music className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">Нет аудио записей</p> <p className="text-white/60">Нет аудио записей</p>
</div>
</div> </div>
) : ( </div>
<div> ) : (
{voiceMessages.map(renderAttachmentCard)} <div>
</div> {voiceMessages.map(renderAttachmentCard)}
)} </div>
</TabsContent> )}
</TabsContent>
<TabsContent value="files" className="mt-0"> <TabsContent value="files" className="mt-0">
{fileMessages.length === 0 ? ( {fileMessages.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" /> <FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">Нет файлов</p> <p className="text-white/60">Нет файлов</p>
</div>
</div> </div>
) : ( </div>
<div> ) : (
{fileMessages.map(renderAttachmentCard)} <div>
</div> {fileMessages.map(renderAttachmentCard)}
)} </div>
</TabsContent> )}
</div> </TabsContent>
</Tabs> </Tabs>
</div> </div>
{/* Lightbox для просмотра изображений */} {/* Lightbox для просмотра изображений */}
{lightboxImage && ( {lightboxImage && (

View File

@ -1,518 +0,0 @@
"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>
)
}

View File

@ -5,7 +5,6 @@ import { useMutation, useQuery } from '@apollo/client'
import { GET_MESSAGES, GET_CONVERSATIONS } 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'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { EmojiPickerComponent } from '@/components/ui/emoji-picker' import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
@ -15,7 +14,7 @@ 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 { MessengerAttachments } from './messenger-attachments' import { MessengerAttachments } from './messenger-attachments'
import { Send, MoreVertical, FileText, MessageCircle } from 'lucide-react' import { Send, FileText, MessageCircle } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
interface Organization { interface Organization {
@ -25,7 +24,10 @@ interface Organization {
fullName?: string fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE' type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string address?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
users?: Array<{ id: string, avatar?: string, managerName?: string }> users?: Array<{ id: string, avatar?: string, managerName?: string }>
createdAt: string
} }
interface Message { interface Message {
@ -60,18 +62,17 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
// Загружаем сообщения с контрагентом // Загружаем сообщения с контрагентом
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, { const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id }, variables: { counterpartyId: counterparty.id },
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений pollInterval: 3000,
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные fetchPolicy: 'cache-and-network',
errorPolicy: 'all' // Показываем данные даже при ошибках errorPolicy: 'all'
}) })
// Мутация для отметки сообщений как прочитанных // Мутация для отметки сообщений как прочитанных
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, { const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
onCompleted: () => { onCompleted: () => {
// Деликатное обновление с задержкой чтобы было незаметно
setTimeout(() => { setTimeout(() => {
onMessagesRead?.() onMessagesRead?.()
}, 500) // Полсекунды задержки для плавности }, 500)
}, },
onError: (error) => { onError: (error) => {
console.error('Ошибка отметки сообщений как прочитанных:', error) console.error('Ошибка отметки сообщений как прочитанных:', error)
@ -80,38 +81,35 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
const [sendMessageMutation] = useMutation(SEND_MESSAGE, { const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
onCompleted: () => { onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки refetch()
},
onError: (error) => {
console.error('Ошибка отправки сообщения:', error)
} }
}) })
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, { const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
onCompleted: () => { onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки refetch()
}, },
onError: (error) => { refetchQueries: [
console.error('Ошибка отправки голосового сообщения:', error) { query: GET_MESSAGES, variables: { counterpartyId: counterparty.id, limit: 1000 } }
} ]
}) })
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, { const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
onCompleted: () => { onCompleted: () => {
refetch() refetch()
}, },
onError: (error) => { refetchQueries: [
console.error('Ошибка отправки изображения:', error) { query: GET_MESSAGES, variables: { counterpartyId: counterparty.id, limit: 1000 } }
} ]
}) })
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, { const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
onCompleted: () => { onCompleted: () => {
refetch() refetch()
}, },
onError: (error) => { refetchQueries: [
console.error('Ошибка отправки файла:', error) { query: GET_MESSAGES, variables: { counterpartyId: counterparty.id, limit: 1000 } }
} ]
}) })
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages]) const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
@ -133,8 +131,6 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
} }
}, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation]) }, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
} }
@ -145,30 +141,21 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
// Автофокус на поле ввода при открытии чата // Автофокус на поле ввода при открытии чата
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { if (currentView === 'chat') {
messageInputRef.current?.focus() const timer = setTimeout(() => {
}, 100) // Небольшая задержка для корректного фокуса messageInputRef.current?.focus()
}, 100)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [counterparty.id]) }
}, [counterparty.id, currentView])
const getOrganizationName = (org: Organization) => { const getOrganizationName = (org: Organization) => {
return org.name || org.fullName || 'Организация' 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 getShortCompanyName = (fullName: string) => { const getShortCompanyName = (fullName: string) => {
if (!fullName) return 'Полное название не указано' if (!fullName) return 'Полное название не указано'
// Словарь для замены полных форм на сокращенные
const legalFormReplacements: { [key: string]: string } = { const legalFormReplacements: { [key: string]: string } = {
'Общество с ограниченной ответственностью': 'ООО', 'Общество с ограниченной ответственностью': 'ООО',
'Открытое акционерное общество': 'ОАО', 'Открытое акционерное общество': 'ОАО',
@ -184,7 +171,6 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
let result = fullName let result = fullName
// Заменяем полные формы на сокращенные
for (const [fullForm, shortForm] of Object.entries(legalFormReplacements)) { for (const [fullForm, shortForm] of Object.entries(legalFormReplacements)) {
const regex = new RegExp(`^${fullForm}\\s+`, 'i') const regex = new RegExp(`^${fullForm}\\s+`, 'i')
if (regex.test(result)) { if (regex.test(result)) {
@ -226,6 +212,11 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
} }
} }
const getInitials = (org: Organization) => {
const name = getOrganizationName(org)
return name.charAt(0).toUpperCase()
}
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!message.trim()) return if (!message.trim()) return
@ -322,8 +313,9 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
return 'Вчера' return 'Вчера'
} else { } else {
return date.toLocaleDateString('ru-RU', { return date.toLocaleDateString('ru-RU', {
day: 'numeric', day: '2-digit',
month: 'long' month: '2-digit',
year: 'numeric'
}) })
} }
} }
@ -345,177 +337,182 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
{getInitials(counterparty)} {getInitials(counterparty)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<div className="flex items-center space-x-3"> <h3 className="font-semibold text-white">
<h3 className="text-white font-medium"> {getOrganizationName(counterparty)}
{getOrganizationName(counterparty)} </h3>
</h3> <div className="flex items-center space-x-2">
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}> <Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
{getTypeLabel(counterparty.type)} {getTypeLabel(counterparty.type)}
</Badge> </Badge>
</div> <p className="text-white/60 text-xs">
<p className="text-white/60 text-sm"> {getShortCompanyName(counterparty.fullName || '')}
{getShortCompanyName(counterparty.fullName || '')}
</p>
</div>
</div>
<Button variant="ghost" size="sm" className="text-white/60 hover:text-white">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
{/* Область сообщений */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-white/60">Загрузка сообщений...</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Send className="h-6 w-6 text-white/40" />
</div>
<p className="text-white/60 text-sm mb-1">Начните беседу</p>
<p className="text-white/40 text-xs">
Отправьте первое сообщение {getOrganizationName(counterparty)}
</p> </p>
</div> </div>
</div> </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}> <div className="flex items-center space-x-2">
{showDate && ( <Button
<div className="flex justify-center mb-4"> variant={currentView === 'chat' ? 'secondary' : 'ghost'}
<div className="bg-white/10 rounded-full px-3 py-1"> size="sm"
<span className="text-white/60 text-xs"> onClick={() => setCurrentView('chat')}
{formatDate(msg.createdAt)} className="text-xs"
</span> >
</div> <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> </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)
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}> return (
<div className="flex flex-col max-w-xs lg:max-w-md break-words"> <div key={msg.id}>
{/* Имя отправителя */} {showDate && (
{!isCurrentUser && ( <div className="flex justify-center mb-4">
<div className="flex items-center space-x-2 mb-1 px-1"> <div className="bg-white/10 rounded-full px-3 py-1">
<Avatar className="h-6 w-6"> <span className="text-white/60 text-xs">
{msg.senderOrganization?.users?.[0]?.avatar ? ( {formatDate(msg.createdAt)}
<AvatarImage </span>
src={msg.senderOrganization.users[0].avatar} </div>
alt="Аватар отправителя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-xs">
{getInitials(msg.senderOrganization)}
</AvatarFallback>
</Avatar>
<span className="text-white/60 text-xs">
{getManagerName(msg.senderOrganization)}
</span>
<Badge className={`${getTypeColor(msg.senderOrganization.type)} text-xs`}>
{getTypeLabel(msg.senderOrganization.type)}
</Badge>
</div> </div>
)} )}
{/* Сообщение */} <div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div className="space-y-2"> <div className={`max-w-xs lg:max-w-md xl:max-w-lg ${
{msg.type === 'VOICE' && msg.voiceUrl ? ( isCurrentUser ? 'mr-2' : 'ml-2'
<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)} <div className="space-y-2">
</p> {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> </div>
</div> )
</div> })
) )}
}) <div ref={messagesEndRef} />
)} </div>
<div ref={messagesEndRef} />
</div>
{/* Поле ввода сообщения */} {/* Поле ввода сообщения */}
<div className="px-4 py-3 border-t border-white/10"> <div className="px-4 py-3 border-t border-white/10">
<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} ref={messageInputRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
placeholder="Введите сообщение..." placeholder="Введите сообщение..."
className="glass-input text-white placeholder:text-white/40 w-full resize-none overflow-y-auto rounded-lg py-2 px-3" className="glass-input text-white placeholder:text-white/40 w-full resize-none overflow-y-auto rounded-lg py-2 px-3"
rows={1} rows={1}
style={{ onInput={(e) => {
height: 'auto', const target = e.target as HTMLTextAreaElement
minHeight: '40px', target.style.height = 'auto'
maxHeight: '120px' target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}} }}
onInput={(e) => { />
const target = e.target as HTMLTextAreaElement </div>
target.style.height = 'auto'
const newHeight = Math.min(Math.max(target.scrollHeight, 40), 120) <div className="flex items-center space-x-1">
target.style.height = newHeight + 'px' <EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
}} <VoiceRecorder onSendVoice={handleSendVoice} />
/> <FileUploader onSendFile={handleSendFile} />
</div> <Button
<div className="flex items-center gap-2"> onClick={handleSendMessage}
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} /> disabled={!message.trim()}
<FileUploader onSendFile={handleSendFile} /> size="icon"
<VoiceRecorder onSendVoice={handleSendVoice} /> className="bg-blue-500 hover:bg-blue-600 text-white"
<Button >
onClick={handleSendMessage} <Send className="h-4 w-4" />
disabled={!message.trim()} </Button>
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer h-10 w-10 p-0" </div>
variant="outline" </div>
>
<Send className="h-4 w-4" />
</Button>
</div> </div>
</>
) : (
/* Вложения */
<div className="flex-1 overflow-hidden">
<MessengerAttachments
counterparty={counterparty}
onViewChange={() => {
// Принудительно обновляем сообщения при переходе на вложения
refetch()
}}
/>
</div> </div>
</div> )}
</div> </div>
) )
} }

View File

@ -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 { MessengerChatWithAttachments } from './messenger-chat-with-attachments' import { MessengerChat } from './messenger-chat'
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'
@ -122,7 +122,7 @@ 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 ? (
<MessengerChatWithAttachments <MessengerChat
counterparty={selectedCounterpartyData} counterparty={selectedCounterpartyData}
onMessagesRead={refetchConversations} onMessagesRead={refetchConversations}
/> />