/** * ContactsList Component v3 * ADR-024: Chat Contacts with improved UX * * Features: * - Chat history section at top * - Contacts with status dot on avatar * - User type as text label * - Working menu actions * - AI agents tab for quick chat * - Tasks tab with configurable columns */ import { logger } from '@/shared/utils/logger'; import { useState, useMemo, useRef, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Users, Bot, User, ListTodo, Search, Zap, X, ChevronRight, Loader2, Settings, Database, Table2, Hash, MoreVertical, MessageSquare, UserPlus, UserMinus, Calendar, History, Trash2 } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { apiClient } from '@/shared/utils/apiClient'; import { AIAgent } from '../types'; export type ContactType = 'human' | 'agent-user'; export interface Contact { id: number; type: ContactType; name: string; email?: string; avatar?: string; status?: 'online' | 'offline' | 'away'; agentTableId?: number; agentRowId?: number; lastMessage?: string; lastMessageTime?: Date; unreadCount?: number; } interface UserData { id: number; name: string; email?: string; avatar_url?: string; user_type?: 'human' | 'agent'; managed_by_agent_table_id?: number; managed_by_agent_row_id?: number; } export interface TasksSourceConfig { tableId: number; tableName: string; tableIcon?: string; displayColumn?: string; deadlineColumn?: string; statusColumn?: string; priorityColumn?: string; } export interface Conversation { id: string; title: string; agentId?: number; agentName?: string; agentIcon?: string; messageCount?: number; lastMessageAt?: string; createdAt?: string; } export interface ContactsListProps { agents: AIAgent[]; onSelectAgent: (agent: AIAgent) => void; onSelectUser?: (userId: number) => void; onSelectTask?: (taskId: number, tableId: number) => void; onConfigureTasks?: () => void; onAddToChat?: (userId: number) => void; onRemoveFromChat?: (userId: number) => void; onStartChatWithUser?: (userId: number) => void; // Conversations conversations?: Conversation[]; currentConversationId?: string; onSelectConversation?: (id: string) => void; onDeleteConversation?: (id: string) => void; onNewConversation?: () => void; isLoadingConversations?: boolean; // Current state currentAgentId?: number; spaceId?: number; tasksSource?: TasksSourceConfig; chatParticipantIds?: number[]; className?: string; } type TabKey = 'contacts' | 'ai-chat' | 'tasks'; type ContactFilter = 'all' | 'humans' | 'agents'; // Contact Menu Component function ContactMenu({ contact, isInChat, onAddToChat, onRemoveFromChat, onStartChat }: { contact: Contact; isInChat: boolean; onAddToChat?: () => void; onRemoveFromChat?: () => void; onStartChat?: () => void; }) { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setIsOpen(false); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen]); const handleStartChat = (e: React.MouseEvent) => { e.stopPropagation(); logger.debug('[ContactMenu] Start chat with', contact.name); onStartChat?.(); setIsOpen(false); }; const handleAddToChat = (e: React.MouseEvent) => { e.stopPropagation(); logger.debug('[ContactMenu] Add to chat', contact.name); onAddToChat?.(); setIsOpen(false); }; const handleRemoveFromChat = (e: React.MouseEvent) => { e.stopPropagation(); logger.debug('[ContactMenu] Remove from chat', contact.name); onRemoveFromChat?.(); setIsOpen(false); }; return (
{isOpen && (
{isInChat ? ( ) : ( )}
)}
); } // Status Dot on Avatar function StatusDot({ status }: { status?: 'online' | 'offline' | 'away' }) { const colors = { online: 'bg-green-500', away: 'bg-yellow-500', offline: 'bg-gray-400' }; return ( ); } // User Type Label function UserTypeLabel({ type }: { type: ContactType }) { if (type === 'agent-user') { return ( AI Агент ); } return ( Человек ); } export function ContactsList({ agents, onSelectAgent, onSelectUser, onSelectTask, onConfigureTasks, onAddToChat, onRemoveFromChat, onStartChatWithUser, conversations = [], currentConversationId, onSelectConversation, onDeleteConversation, onNewConversation, isLoadingConversations, currentAgentId, spaceId, tasksSource, chatParticipantIds = [], className }: ContactsListProps) { const [activeTab, setActiveTab] = useState('contacts'); const [searchQuery, setSearchQuery] = useState(''); const [contactFilter, setContactFilter] = useState('all'); const [showHistory, setShowHistory] = useState(conversations.length > 0); // Fetch ALL users const { data: users = [], isLoading: isLoadingUsers } = useQuery({ queryKey: ['chat-users-all', spaceId], queryFn: async () => { const response = await apiClient.get<{ success: boolean; data: UserData[]; }>('/users'); return response.success ? response.data : []; } }); // Fetch tasks from configured table const { data: taskRows = [], isLoading: isLoadingTasks } = useQuery({ queryKey: ['task-rows', tasksSource?.tableId], queryFn: async () => { if (!tasksSource?.tableId) return []; const response = await apiClient.get<{ success: boolean; data: { rows: Array<{ id: number; data: Record }> }; }>(`/tables/${tasksSource.tableId}/rows?limit=100`); return response.success ? response.data.rows : []; }, enabled: !!tasksSource?.tableId && activeTab === 'tasks' }); // Convert users to contacts const allContacts: Contact[] = useMemo(() => { return users.map(user => ({ id: user.id, type: user.managed_by_agent_table_id != null ? 'agent-user' as const : 'human' as const, name: user.name, email: user.email, avatar: user.avatar_url, agentTableId: user.managed_by_agent_table_id, agentRowId: user.managed_by_agent_row_id, status: 'offline' as const })); }, [users]); const humanContacts = useMemo(() => allContacts.filter(c => c.type === 'human'), [allContacts]); const agentUserContacts = useMemo(() => allContacts.filter(c => c.type === 'agent-user'), [allContacts]); // Filter contacts const filteredContacts = useMemo(() => { let contacts = allContacts; if (contactFilter === 'humans') { contacts = humanContacts; } else if (contactFilter === 'agents') { contacts = agentUserContacts; } if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); contacts = contacts.filter(c => c.name.toLowerCase().includes(query) || c.email?.toLowerCase().includes(query) ); } return contacts; }, [allContacts, humanContacts, agentUserContacts, contactFilter, searchQuery]); // Filter AI agents const filteredAgents = useMemo(() => { if (!searchQuery.trim()) return agents; const query = searchQuery.toLowerCase(); return agents.filter(a => a.name.toLowerCase().includes(query) || a.description?.toLowerCase().includes(query) ); }, [agents, searchQuery]); // Filter conversations const filteredConversations = useMemo(() => { if (!searchQuery.trim()) return conversations; const query = searchQuery.toLowerCase(); return conversations.filter(c => c.title.toLowerCase().includes(query) || c.agentName?.toLowerCase().includes(query) ); }, [conversations, searchQuery]); const handleContactClick = (contact: Contact) => { logger.debug('[ContactsList] Contact clicked', contact.name, contact.id); onSelectUser?.(contact.id); }; const handleStartChat = (contact: Contact) => { logger.debug('[ContactsList] Start chat with', contact.name, contact.id); if (onStartChatWithUser) { onStartChatWithUser(contact.id); } else if (onSelectUser) { onSelectUser(contact.id); } }; const handleTaskClick = (taskId: number) => { if (tasksSource) { onSelectTask?.(taskId, tasksSource.tableId); } }; const tabs = [ { key: 'contacts' as TabKey, label: 'Контакты', icon: , count: allContacts.length }, { key: 'ai-chat' as TabKey, label: 'AI Чат', icon: , count: agents.length }, { key: 'tasks' as TabKey, label: 'Задачи', icon: }, ]; return (
{/* Chat History Section (collapsible) */} {conversations.length > 0 && (
{showHistory && (
{isLoadingConversations ? (
) : filteredConversations.length === 0 ? (
{searchQuery ? 'Не найдено' : 'Нет истории'}
) : ( filteredConversations.map(conv => (
)) )}
)}
)} {/* Tabs */}
{tabs.map(tab => ( ))}
{/* Toolbar */}
{/* Search */}
setSearchQuery(e.target.value)} placeholder={ activeTab === 'contacts' ? 'Поиск контактов...' : activeTab === 'ai-chat' ? 'Поиск агентов...' : 'Поиск задач...' } className="w-full pl-8 pr-8 py-1.5 text-sm rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] border-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30" /> {searchQuery && ( )}
{/* Filter for Contacts tab */} {activeTab === 'contacts' && ( )} {/* Settings for Tasks tab */} {activeTab === 'tasks' && onConfigureTasks && ( )}
{/* Content */}
{/* Contacts Tab */} {activeTab === 'contacts' && ( isLoadingUsers ? (
) : filteredContacts.length === 0 ? (

{searchQuery ? 'Ничего не найдено' : 'Нет контактов'}

) : (
{filteredContacts.map(contact => { const isInChat = chatParticipantIds.includes(contact.id); return (
{/* Avatar with Status Dot */} {/* Menu */} { logger.debug('[ContactsList] onAddToChat callback', contact.id); onAddToChat?.(contact.id); }} onRemoveFromChat={() => { logger.debug('[ContactsList] onRemoveFromChat callback', contact.id); onRemoveFromChat?.(contact.id); }} onStartChat={() => handleStartChat(contact)} />
); })}
) )} {/* AI Chat Tab */} {activeTab === 'ai-chat' && ( filteredAgents.length === 0 ? (

{searchQuery ? 'Агенты не найдены' : 'Нет доступных агентов'}

) : (
{filteredAgents.map(agent => ( ))}
) )} {/* Tasks Tab */} {activeTab === 'tasks' && ( tasksSource ? (
{/* Source header */}
{tasksSource.tableIcon || '📋'} {tasksSource.tableName}
{isLoadingTasks ? (
) : taskRows.length === 0 ? (
Нет записей в таблице
) : ( taskRows.map(row => { const displayCol = tasksSource.displayColumn || 'name'; const title = String(row.data[displayCol] || row.data['title'] || row.data['name'] || `#${row.id}`); const deadline = tasksSource.deadlineColumn ? row.data[tasksSource.deadlineColumn] as string | null : null; const status = tasksSource.statusColumn ? row.data[tasksSource.statusColumn] as string | null : null; return ( ); }) )}
) : (

Источник не настроен

Выберите таблицу для отображения записей

{onConfigureTasks && ( )}
) )}
); }