406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useQuery } from '@apollo/client'
|
||
import { GET_MESSAGES } from '@/graphql/queries'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
import { Card } from '@/components/ui/card'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { ImageLightbox } from '@/components/ui/image-lightbox'
|
||
import { VoicePlayer } from '@/components/ui/voice-player'
|
||
import {
|
||
FileText,
|
||
Image,
|
||
Music,
|
||
Video,
|
||
Download,
|
||
Calendar,
|
||
User,
|
||
RefreshCw
|
||
} from 'lucide-react'
|
||
|
||
interface Organization {
|
||
id: string
|
||
inn: string
|
||
name?: string
|
||
fullName?: string
|
||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||
}
|
||
|
||
interface Message {
|
||
id: string
|
||
content?: string
|
||
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE'
|
||
voiceUrl?: string
|
||
voiceDuration?: number
|
||
fileUrl?: string
|
||
fileName?: string
|
||
fileSize?: number
|
||
fileType?: string
|
||
senderId: string
|
||
senderOrganization: Organization
|
||
receiverOrganization: Organization
|
||
createdAt: string
|
||
isRead: boolean
|
||
}
|
||
|
||
interface MessengerAttachmentsProps {
|
||
counterparty: Organization
|
||
onViewChange?: () => void
|
||
}
|
||
|
||
export function MessengerAttachments({ counterparty, onViewChange }: MessengerAttachmentsProps) {
|
||
const { user } = useAuth()
|
||
const [activeTab, setActiveTab] = useState('all')
|
||
const [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null)
|
||
|
||
// Загружаем все сообщения для получения вложений
|
||
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||
variables: { counterpartyId: counterparty.id, limit: 1000 },
|
||
fetchPolicy: 'cache-and-network',
|
||
pollInterval: 5000, // Обновляем каждые 5 секунд
|
||
notifyOnNetworkStatusChange: false, // Не показываем loading при обновлениях
|
||
})
|
||
|
||
// Обновляем данные при открытии вкладки вложений
|
||
useEffect(() => {
|
||
onViewChange?.()
|
||
}, [onViewChange])
|
||
|
||
const messages: Message[] = messagesData?.messages || []
|
||
|
||
// Фильтруем только сообщения с вложениями
|
||
const attachmentMessages = messages.filter(msg =>
|
||
msg.type && ['VOICE', 'IMAGE', 'FILE'].includes(msg.type) &&
|
||
(msg.fileUrl || (msg.type === 'VOICE' && msg.voiceUrl))
|
||
)
|
||
|
||
// Группируем по типам
|
||
const imageMessages = attachmentMessages.filter(msg => msg.type === 'IMAGE')
|
||
const voiceMessages = attachmentMessages.filter(msg => msg.type === 'VOICE')
|
||
const fileMessages = attachmentMessages.filter(msg => msg.type === 'FILE')
|
||
|
||
const getOrganizationName = (org: Organization) => {
|
||
return org.name || org.fullName || 'Организация'
|
||
}
|
||
|
||
const getInitials = (org: Organization) => {
|
||
const name = getOrganizationName(org)
|
||
return name.charAt(0).toUpperCase()
|
||
}
|
||
|
||
const formatFileSize = (bytes?: number) => {
|
||
if (!bytes) return '0 B'
|
||
const k = 1024
|
||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString)
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
})
|
||
}
|
||
|
||
const formatTime = (dateString: string) => {
|
||
const date = new Date(dateString)
|
||
return date.toLocaleTimeString('ru-RU', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
|
||
const handleDownload = (fileUrl: string, fileName: string) => {
|
||
const link = document.createElement('a')
|
||
link.href = fileUrl
|
||
link.download = fileName
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
}
|
||
|
||
const handleImageClick = (imageUrl: string, fileName?: string, fileSize?: number) => {
|
||
setLightboxImage({
|
||
url: imageUrl,
|
||
fileName: fileName || 'Изображение',
|
||
fileSize
|
||
})
|
||
}
|
||
|
||
const renderFileIcon = (fileType?: string) => {
|
||
if (!fileType) return <FileText className="h-4 w-4 text-gray-400" />
|
||
|
||
if (fileType.startsWith('image/')) return <Image className="h-4 w-4 text-blue-400" aria-hidden="true" />
|
||
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 text-purple-400" />
|
||
return <FileText className="h-4 w-4 text-gray-400" />
|
||
}
|
||
|
||
const renderAttachmentCard = (message: Message) => {
|
||
const isCurrentUser = message.senderOrganization?.id === user?.organization?.id
|
||
const senderOrg = isCurrentUser ? user?.organization : message.senderOrganization
|
||
|
||
return (
|
||
<Card key={message.id} className="glass-card p-4 mb-3">
|
||
<div className="flex items-start space-x-3">
|
||
{/* Аватар отправителя */}
|
||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||
{(senderOrg as Organization)?.users?.[0]?.avatar ? (
|
||
<AvatarImage
|
||
src={(senderOrg as Organization).users![0].avatar!}
|
||
alt="Аватар отправителя"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : null}
|
||
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||
{getInitials(senderOrg as Organization)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-white/80 text-sm font-medium">
|
||
{isCurrentUser ? 'Вы' : getOrganizationName(senderOrg as Organization)}
|
||
</span>
|
||
<Badge variant="outline" className="text-xs">
|
||
{isCurrentUser ? 'Исходящий' : 'Входящий'}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center space-x-2 text-xs text-white/50">
|
||
<Calendar className="h-3 w-3" />
|
||
<span>{formatDate(message.createdAt)}</span>
|
||
<span>{formatTime(message.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Содержимое вложения */}
|
||
{message.type === 'VOICE' && message.voiceUrl ? (
|
||
/* Голосовой плеер */
|
||
<div className="w-full">
|
||
<VoicePlayer
|
||
audioUrl={message.voiceUrl}
|
||
duration={message.voiceDuration || 0}
|
||
isCurrentUser={isCurrentUser}
|
||
/>
|
||
</div>
|
||
) : (
|
||
/* Обычные файлы и изображения */
|
||
<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">
|
||
{/* Превью изображения или иконка */}
|
||
{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" aria-hidden="true" />
|
||
</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>
|
||
|
||
<button
|
||
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"
|
||
title="Скачать файл"
|
||
>
|
||
<Download className="h-4 w-4 text-white/70" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-white/60">Загрузка вложений...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (attachmentMessages.length === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60 text-lg mb-2">Нет вложений</p>
|
||
<p className="text-white/40 text-sm">
|
||
Файлы, изображения и голосовые сообщения будут отображаться здесь
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
<div className="flex-1 overflow-auto p-4">
|
||
{/* Кнопка обновления */}
|
||
<div className="flex justify-between items-center mb-4">
|
||
<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 value="images" className="text-xs">
|
||
Фото ({imageMessages.length})
|
||
</TabsTrigger>
|
||
<TabsTrigger value="audio" className="text-xs">
|
||
Аудио ({voiceMessages.length})
|
||
</TabsTrigger>
|
||
<TabsTrigger value="files" className="text-xs">
|
||
Файлы ({fileMessages.length})
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="all" className="mt-0">
|
||
{attachmentMessages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Нет вложений</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{attachmentMessages.map(renderAttachmentCard)}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="images" className="mt-0">
|
||
{imageMessages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<Image className="h-12 w-12 text-white/20 mx-auto mb-4" aria-hidden="true" />
|
||
<p className="text-white/60">Нет изображений</p>
|
||
</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
|
||
key={`grid-${msg.id}`}
|
||
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!}
|
||
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" aria-hidden="true" />
|
||
</div>
|
||
<div className="absolute bottom-2 left-2 right-2">
|
||
<div className="text-white text-xs truncate bg-black/50 px-2 py-1 rounded">
|
||
{formatDate(msg.createdAt)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Детальный список карточек */}
|
||
<div className="border-t border-white/10 pt-4">
|
||
<h4 className="text-white/80 text-sm font-medium mb-3">Детальная информация</h4>
|
||
{imageMessages.map(renderAttachmentCard)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="audio" className="mt-0">
|
||
{voiceMessages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<Music className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Нет аудио записей</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{voiceMessages.map(renderAttachmentCard)}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="files" className="mt-0">
|
||
{fileMessages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Нет файлов</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{fileMessages.map(renderAttachmentCard)}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
{/* Lightbox для просмотра изображений */}
|
||
{lightboxImage && (
|
||
<ImageLightbox
|
||
imageUrl={lightboxImage.url}
|
||
fileName={lightboxImage.fileName}
|
||
fileSize={lightboxImage.fileSize}
|
||
isOpen={true}
|
||
onClose={() => setLightboxImage(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|