518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
"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>
|
||
)
|
||
}
|