diff --git a/src/components/chat/chat-sidebar/ChatSidebar.tsx b/src/components/chat/chat-sidebar/ChatSidebar.tsx index f9522aff..514bef46 100644 --- a/src/components/chat/chat-sidebar/ChatSidebar.tsx +++ b/src/components/chat/chat-sidebar/ChatSidebar.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Button } from '@evoapi/design-system/button'; +import { Checkbox } from '@evoapi/design-system/checkbox'; import { Input } from '@evoapi/design-system/input'; import { Badge } from '@evoapi/design-system/badge'; import { @@ -93,6 +94,12 @@ interface ChatSidebarProps { onAssignTeam: (conversation: Conversation) => void; onAssignTag: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void; + selectedConversationIds: Set; + onToggleSelect: (displayId: string) => void; + onClearSelection: () => void; + onBulkResolve: () => Promise; + isBulkResolving?: boolean; + canBulkResolve?: boolean; } const ChatSidebar = ({ @@ -117,6 +124,12 @@ const ChatSidebar = ({ onAssignTeam, onAssignTag, onDeleteConversation, + selectedConversationIds, + onToggleSelect, + onClearSelection, + onBulkResolve, + isBulkResolving = false, + canBulkResolve = true, }: ChatSidebarProps) => { const { t } = useLanguage('chat'); const chatContext = useChatContext(); @@ -145,6 +158,10 @@ const ChatSidebar = ({ const sidebarScrollRef = useRef(null); const loadingMoreRef = useRef(false); + useEffect(() => { + onClearSelection(); + }, [showArchived, onClearSelection]); + // Pipeline state const [allPipelines, setAllPipelines] = useState([]); const [isPipelinesLoaded, setIsPipelinesLoaded] = useState(false); @@ -885,7 +902,7 @@ const ChatSidebar = ({ data-tour="chat-sidebar" className={` ${mobileView === 'list' ? 'flex' : 'hidden'} md:flex - w-full md:w-80 border-r bg-card/50 flex-col h-full + w-full ${selectedConversationIds.size > 0 ? 'md:w-96' : 'md:w-80'} border-r bg-card/50 flex-col h-full `} > {/* Search and Filter Header */} @@ -973,6 +990,34 @@ const ChatSidebar = ({ )} + {/* Bulk Action Toolbar */} + {selectedConversationIds.size > 0 && ( +
+
+ + {t('chatSidebar.selectedCount', { count: selectedConversationIds.size })} + + +
+ +
+ )} + {/* Conversations List */}
+
e.stopPropagation()} + > + { + const isSelected = selectedConversationIds.has(String(conversation.display_id)); + if ((checked === true && !isSelected) || (checked === false && isSelected)) { + onToggleSelect(String(conversation.display_id)); + } + }} + aria-label={t('chatSidebar.selectConversation')} + className="bg-white dark:bg-zinc-700 border-2 border-zinc-400 dark:border-zinc-500 data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> +
import('@/components/chat/contact-sidebar/ContactSidebar')); @@ -103,6 +104,10 @@ const Chat = () => { const [assignmentType, setAssignmentType] = useState('agent'); const [conversationToAssign, setConversationToAssign] = useState(null); + // Bulk selection state + const [selectedConversationIds, setSelectedConversationIds] = useState>(new Set()); + const [isBulkResolving, setIsBulkResolving] = useState(false); + // Dashboard Apps state (lazy loaded, not auto-fetched) const [dashboardApps] = useState([]); const [activeTab, setActiveTab] = useState('chat'); @@ -132,6 +137,7 @@ const Chat = () => { // 🎯 FILTROS: Usar handlers dos hooks customizados (DEFINIR ANTES DOS useEffect) const handleApplyFilters = useCallback( async (newFilters: BaseFilter[]) => { + setSelectedConversationIds(new Set()); try { await filterHandlers.handleApplyFilters(newFilters); } catch (error) { @@ -180,6 +186,7 @@ const Chat = () => { }, [permissionsReady]); const handleClearFilters = useCallback(async () => { + setSelectedConversationIds(new Set()); await filterHandlers.handleClearFilters(); }, [filterHandlers]); @@ -187,6 +194,54 @@ const Chat = () => { await filterHandlers.reloadCurrentFilters(); }, [filterHandlers]); + const MAX_BULK_SELECTION = 200; + + const handleToggleConversationSelection = useCallback((displayId: string) => { + setSelectedConversationIds(prev => { + const next = new Set(prev); + if (next.has(displayId)) { + next.delete(displayId); + } else if (next.size < MAX_BULK_SELECTION) { + next.add(displayId); + } + return next; + }); + }, []); + + const handleClearSelection = useCallback(() => { + setSelectedConversationIds(new Set()); + }, []); + + const handleBulkResolve = useCallback(async () => { + if (selectedConversationIds.size === 0) return; + if (!can('conversations', 'update')) { + toast.error(t('chatHeader.actions.bulkResolveNoPermission')); + return; + } + const displayIds = Array.from(selectedConversationIds); + setIsBulkResolving(true); + try { + const result = await chatService.bulkResolve(displayIds); + setSelectedConversationIds(new Set()); + if (result.failed_ids.length === 0) { + toast.success(t('chatHeader.actions.bulkResolveSuccess', { count: result.success_ids.length })); + } else if (result.success_ids.length > 0) { + toast.warning(t('chatHeader.actions.bulkResolvePartialSuccess', { + success: result.success_ids.length, + failed: result.failed_ids.length, + })); + } else { + toast.error(t('chatHeader.actions.bulkResolveError')); + } + await reloadCurrentFilters(); + } catch (error) { + console.error('Bulk resolve error:', error); + toast.error(t('chatHeader.actions.bulkResolveError')); + } finally { + setIsBulkResolving(false); + } + }, [selectedConversationIds, can, reloadCurrentFilters, t]); + // 🔄 CARREGAMENTO SIMPLES: Apenas carregar mensagens quando conversa muda useEffect(() => { if (conversations.state.selectedConversationId) { @@ -655,6 +710,8 @@ const Chat = () => { return; } + setSelectedConversationIds(new Set()); + // 🔒 MARCAR NAVEGAÇÃO MANUAL para evitar URL sync isManualNavigationRef.current = true; @@ -714,6 +771,12 @@ const Chat = () => { onAssignTeam={handleAssignTeam} onAssignTag={handleAssignTag} onDeleteConversation={handleDeleteConversation} + selectedConversationIds={selectedConversationIds} + onToggleSelect={handleToggleConversationSelection} + onClearSelection={handleClearSelection} + onBulkResolve={handleBulkResolve} + isBulkResolving={isBulkResolving} + canBulkResolve={can('conversations', 'update')} /> {/* Chat Area */} diff --git a/src/services/chat/chatService.bulkResolve.spec.ts b/src/services/chat/chatService.bulkResolve.spec.ts new file mode 100644 index 00000000..023469d1 --- /dev/null +++ b/src/services/chat/chatService.bulkResolve.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import chatService from './chatService'; +import api from '@/services/core/api'; + +vi.mock('@/services/core/api', () => ({ + default: { + post: vi.fn(), + get: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('@/utils/retry/retryHelper', () => ({ + withRetry: (op: () => Promise) => op(), +})); + +describe('chatService.bulkResolve', () => { + const postMock = vi.mocked(api.post); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends POST /bulk_actions with correct payload', async () => { + postMock.mockResolvedValue({ + data: { data: { success_ids: [1, 2], failed_ids: [] } }, + } as never); + + await chatService.bulkResolve(['1', '2']); + + expect(postMock).toHaveBeenCalledWith('/bulk_actions', { + type: 'Conversation', + ids: ['1', '2'], + fields: { status: 'resolved' }, + }); + }); + + it('returns success_ids and failed_ids from response', async () => { + postMock.mockResolvedValue({ + data: { data: { success_ids: [10, 11], failed_ids: [12] } }, + } as never); + + const result = await chatService.bulkResolve(['10', '11', '12']); + + expect(result.success_ids).toEqual([10, 11]); + expect(result.failed_ids).toEqual([12]); + }); + + it('returns empty arrays when response data is absent', async () => { + postMock.mockResolvedValue({ data: {} } as never); + + const result = await chatService.bulkResolve(['1']); + + expect(result.success_ids).toEqual([]); + expect(result.failed_ids).toEqual([]); + }); + + it('caps selection toggle at MAX_BULK_SELECTION (200) items', () => { + const MAX = 200; + let selection = new Set(); + + const toggle = (id: string) => { + const next = new Set(selection); + if (next.has(id)) { + next.delete(id); + } else if (next.size < MAX) { + next.add(id); + } + selection = next; + }; + + for (let i = 0; i < MAX + 10; i++) { + toggle(String(i)); + } + expect(selection.size).toBe(MAX); + + toggle('0'); + expect(selection.size).toBe(MAX - 1); + + toggle('9999'); + expect(selection.size).toBe(MAX); + }); +}); diff --git a/src/services/chat/chatService.ts b/src/services/chat/chatService.ts index b130af2e..5fc622fd 100644 --- a/src/services/chat/chatService.ts +++ b/src/services/chat/chatService.ts @@ -281,6 +281,16 @@ class ChatService { ); return extractData(response); } + + async bulkResolve(displayIds: string[]): Promise<{ success_ids: number[]; failed_ids: number[] }> { + const response = await api.post('/bulk_actions', { + type: 'Conversation', + ids: displayIds, + fields: { status: 'resolved' }, + }); + const data = response.data?.data; + return data ?? { success_ids: [], failed_ids: [] }; + } } // Export singleton instance