336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
"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 Conversation {
|
||
id: string
|
||
counterparty: Organization
|
||
lastMessage?: {
|
||
id: string
|
||
content?: string
|
||
type?: string
|
||
senderId: string
|
||
isRead: boolean
|
||
createdAt: string
|
||
}
|
||
unreadCount: number
|
||
updatedAt: string
|
||
}
|
||
|
||
interface MessengerConversationsProps {
|
||
conversations?: Conversation[]
|
||
counterparties: Organization[]
|
||
loading: boolean
|
||
selectedCounterparty: string | null
|
||
onSelectCounterparty: (counterpartyId: string) => void
|
||
onRefresh?: () => void
|
||
compact?: boolean
|
||
}
|
||
|
||
export function MessengerConversations({
|
||
conversations = [],
|
||
counterparties,
|
||
loading,
|
||
selectedCounterparty,
|
||
onSelectCounterparty,
|
||
onRefresh,
|
||
compact = false
|
||
}: 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 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 allCounterparties = new Map<string, { org: Organization; conversation?: Conversation }>()
|
||
|
||
// Сначала добавляем из чатов
|
||
conversations.forEach(conv => {
|
||
allCounterparties.set(conv.counterparty.id, {
|
||
org: conv.counterparty,
|
||
conversation: conv
|
||
})
|
||
})
|
||
|
||
// Затем добавляем остальных контрагентов, если их еще нет
|
||
counterparties.forEach(org => {
|
||
if (!allCounterparties.has(org.id)) {
|
||
allCounterparties.set(org.id, { org })
|
||
}
|
||
})
|
||
|
||
const filteredCounterparties = Array.from(allCounterparties.values()).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)
|
||
}).sort((a, b) => {
|
||
// Сортируем: сначала с активными чатами, потом по времени последнего сообщения
|
||
if (a.conversation && !b.conversation) return -1
|
||
if (!a.conversation && b.conversation) return 1
|
||
if (a.conversation && b.conversation) {
|
||
return new Date(b.conversation.updatedAt).getTime() - new Date(a.conversation.updatedAt).getTime()
|
||
}
|
||
return 0
|
||
})
|
||
|
||
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">
|
||
{/* Заголовок */}
|
||
{!compact && (
|
||
<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">
|
||
{conversations.length > 0 ? `${conversations.length} активных чатов` : `${counterparties.length} контрагентов`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Компактный заголовок */}
|
||
{compact && (
|
||
<div className="flex items-center justify-center mb-3">
|
||
<Users className="h-4 w-4 text-blue-400 mr-2" />
|
||
<span className="text-white font-medium text-sm">{allCounterparties.size}</span>
|
||
</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={compact ? "Поиск..." : "Поиск по названию или ИНН..."}
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className={`glass-input text-white placeholder:text-white/40 pl-10 ${compact ? 'h-8 text-sm' : '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, conversation }) => (
|
||
<div
|
||
key={org.id}
|
||
onClick={() => {
|
||
onSelectCounterparty(org.id)
|
||
// Если есть непрочитанные сообщения и функция обновления, вызываем её
|
||
if (conversation?.unreadCount && conversation.unreadCount > 0 && onRefresh) {
|
||
setTimeout(() => onRefresh(), 100) // Небольшая задержка для UI
|
||
}
|
||
}}
|
||
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 relative ${
|
||
selectedCounterparty === org.id
|
||
? 'bg-white/20 border border-white/30'
|
||
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
||
}`}
|
||
>
|
||
{compact ? (
|
||
/* Компактный режим */
|
||
<div className="flex items-center justify-center relative">
|
||
<Avatar className="h-8 w-8">
|
||
{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-xs">
|
||
{getInitials(org)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{/* Индикатор непрочитанных сообщений для компактного режима */}
|
||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
|
||
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* Обычный режим */
|
||
<div className="flex items-start space-x-3">
|
||
<div className="relative">
|
||
<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>
|
||
{/* Индикатор непрочитанных сообщений */}
|
||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
|
||
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h4 className={`font-medium text-sm leading-tight truncate ${
|
||
conversation?.unreadCount && conversation.unreadCount > 0
|
||
? 'text-white'
|
||
: 'text-white/80'
|
||
}`}>
|
||
{getOrganizationName(org)}
|
||
</h4>
|
||
<div className="flex items-center space-x-2">
|
||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30 text-xs">
|
||
{conversation.unreadCount}
|
||
</Badge>
|
||
)}
|
||
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0`}>
|
||
{getTypeLabel(org.type)}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{conversation?.lastMessage ? (
|
||
<p className={`text-xs truncate ${
|
||
conversation.unreadCount && conversation.unreadCount > 0
|
||
? 'text-white/80'
|
||
: 'text-white/60'
|
||
}`}>
|
||
{conversation.lastMessage.type === 'TEXT'
|
||
? conversation.lastMessage.content
|
||
: conversation.lastMessage.type === 'VOICE'
|
||
? '🎵 Голосовое сообщение'
|
||
: conversation.lastMessage.type === 'IMAGE'
|
||
? '🖼️ Изображение'
|
||
: conversation.lastMessage.type === 'FILE'
|
||
? '📄 Файл'
|
||
: 'Сообщение'
|
||
}
|
||
</p>
|
||
) : (
|
||
<p className="text-white/60 text-xs truncate">
|
||
{getShortCompanyName(org.fullName || '')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|