Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
This commit is contained in:
432
src/components/messenger/messenger-chat.tsx
Normal file
432
src/components/messenger/messenger-chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
178
src/components/messenger/messenger-conversations.tsx
Normal file
178
src/components/messenger/messenger-conversations.tsx
Normal 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>
|
||||
)
|
||||
}
|
113
src/components/messenger/messenger-dashboard.tsx
Normal file
113
src/components/messenger/messenger-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
48
src/components/messenger/messenger-empty-state.tsx
Normal file
48
src/components/messenger/messenger-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user