Files
sfera-new/src/components/messenger/messenger-chat-with-attachments.tsx

518 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}