Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.

This commit is contained in:
Bivekich
2025-07-16 22:07:38 +03:00
parent 823ef9a28c
commit 205c9eae98
33 changed files with 3288 additions and 229 deletions

View File

@ -0,0 +1,432 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { GET_MESSAGES } from '@/graphql/queries'
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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 { Send, MoreVertical } 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
users?: Array<{ id: string, avatar?: string, managerName?: 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
}
export function MessengerChat({ counterparty }: MessengerChatProps) {
const { user } = useAuth()
const [message, setMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
// Загружаем сообщения с контрагентом
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id },
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
errorPolicy: 'all' // Показываем данные даже при ошибках
})
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки сообщения:', error)
}
})
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки голосового сообщения:', error)
}
})
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки изображения:', error)
}
})
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки файла:', error)
}
})
const messages = messagesData?.messages || []
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
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 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 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 handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => {
try {
if (messageType === 'IMAGE') {
await sendImageMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
fileUrl,
fileName,
fileSize,
fileType
}
})
} else {
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: 'numeric',
month: 'long'
})
}
}
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="text-white font-medium">
{getOrganizationName(counterparty)}
</h3>
<p className="text-white/60 text-sm mb-1">
{getManagerName(counterparty)}
</p>
<div className="flex items-center space-x-2">
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
{getTypeLabel(counterparty.type)}
</Badge>
</div>
</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>
</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="flex flex-col max-w-xs lg:max-w-md">
{/* Имя отправителя */}
{!isCurrentUser && (
<div className="flex items-center space-x-2 mb-1 px-1">
<Avatar className="h-6 w-6">
{msg.senderOrganization?.users?.[0]?.avatar ? (
<AvatarImage
src={msg.senderOrganization.users[0].avatar}
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 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 ${
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">{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="p-4 border-t border-white/10">
<div className="flex space-x-2">
<div className="flex flex-1 space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Введите сообщение..."
className="glass-input text-white placeholder:text-white/40 flex-1"
/>
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
<FileUploader onSendFile={handleSendFile} />
<VoiceRecorder onSendVoice={handleSendVoice} />
</div>
<Button
onClick={handleSendMessage}
disabled={!message.trim()}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
variant="outline"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,178 @@
"use client"
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Users, Search } from 'lucide-react'
import { useState } from 'react'
interface Organization {
id: string
inn: string
name?: string
fullName?: string
managementName?: 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 MessengerConversationsProps {
counterparties: Organization[]
loading: boolean
selectedCounterparty: string | null
onSelectCounterparty: (counterpartyId: string) => void
}
export function MessengerConversations({
counterparties,
loading,
selectedCounterparty,
onSelectCounterparty
}: MessengerConversationsProps) {
const [searchTerm, setSearchTerm] = useState('')
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 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 filteredCounterparties = counterparties.filter(org => {
if (!searchTerm) return true
const name = getOrganizationName(org).toLowerCase()
const managerName = getManagerName(org).toLowerCase()
const inn = org.inn.toLowerCase()
const search = searchTerm.toLowerCase()
return name.includes(search) || inn.includes(search) || managerName.includes(search)
})
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-white/60">Загрузка...</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Заголовок */}
<div className="flex items-center space-x-3 mb-4">
<Users className="h-5 w-5 text-blue-400" />
<div>
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
</div>
</div>
{/* Поиск */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск по названию или ИНН..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="glass-input text-white placeholder:text-white/40 pl-10 h-10"
/>
</div>
{/* Список контрагентов */}
<div className="flex-1 overflow-auto space-y-2">
{filteredCounterparties.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="h-6 w-6 text-white/40" />
</div>
<p className="text-white/60 text-sm">
{searchTerm ? 'Ничего не найдено' : 'Контрагенты не найдены'}
</p>
</div>
) : (
filteredCounterparties.map((org) => (
<div
key={org.id}
onClick={() => onSelectCounterparty(org.id)}
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 ${
selectedCounterparty === org.id
? 'bg-white/20 border border-white/30'
: 'bg-white/5 hover:bg-white/10 border border-white/10'
}`}
>
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium text-sm leading-tight truncate mb-1">
{getOrganizationName(org)}
</h4>
<div className="flex items-center space-x-2 mb-2">
<Badge className={`${getTypeColor(org.type)} text-xs`}>
{getTypeLabel(org.type)}
</Badge>
</div>
<p className="text-white/60 text-xs truncate">
{getManagerName(org)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,113 @@
"use client"
import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Sidebar } from '@/components/dashboard/sidebar'
import { MessengerConversations } from './messenger-conversations'
import { MessengerChat } from './messenger-chat'
import { MessengerEmptyState } from './messenger-empty-state'
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
import { MessageCircle } from 'lucide-react'
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 }>
createdAt: string
}
export function MessengerDashboard() {
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
const counterparties = counterpartiesData?.myCounterparties || []
const handleSelectCounterparty = (counterpartyId: string) => {
setSelectedCounterparty(counterpartyId)
}
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
// Если нет контрагентов, показываем заглушку
if (!counterpartiesLoading && counterparties.length === 0) {
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
<p className="text-white/70 text-sm">Общение с контрагентами</p>
</div>
</div>
<div className="flex-1 overflow-hidden">
<Card className="glass-card h-full overflow-hidden p-6">
<MessengerEmptyState />
</Card>
</div>
</div>
</main>
</div>
)
}
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
{/* Заголовок - фиксированная высота */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
<p className="text-white/70 text-sm">Общение с контрагентами</p>
</div>
</div>
{/* Основной контент - сетка из 2 колонок */}
<div className="flex-1 overflow-hidden">
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
{/* Левая колонка - список контрагентов */}
<Card className="glass-card h-full overflow-hidden p-4">
<MessengerConversations
counterparties={counterparties}
loading={counterpartiesLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
/>
</Card>
{/* Правая колонка - чат */}
<Card className="glass-card h-full overflow-hidden">
{selectedCounterparty && selectedCounterpartyData ? (
<MessengerChat counterparty={selectedCounterpartyData} />
) : (
<div className="flex items-center justify-center h-full">
<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">
Начните беседу с одним из ваших контрагентов
</p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { Button } from '@/components/ui/button'
import { MessageCircle, Store, Users } from 'lucide-react'
import { useRouter } from 'next/navigation'
export function MessengerEmptyState() {
const router = useRouter()
const handleGoToMarket = () => {
router.push('/market')
}
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
<MessageCircle className="h-10 w-10 text-white/40" />
</div>
<h3 className="text-xl font-semibold text-white mb-3">
У вас пока нет контрагентов
</h3>
<p className="text-white/60 text-sm mb-6 leading-relaxed">
Чтобы начать общение, сначала найдите и добавьте контрагентов в разделе «Маркет».
После добавления они появятся здесь для общения.
</p>
<div className="space-y-3">
<Button
onClick={handleGoToMarket}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 w-full cursor-pointer"
variant="outline"
>
<Store className="h-4 w-4 mr-2" />
Перейти в Маркет
</Button>
<div className="flex items-center justify-center text-white/40 text-xs">
<Users className="h-3 w-3 mr-1" />
Найдите партнеров и начните общение
</div>
</div>
</div>
</div>
)
}