Skip to content

Commit 727b01f

Browse files
feat(chat): bulk resolve conversations via checkbox selection (EVO-1011)
Add multi-select checkboxes to the conversation list sidebar with a bulk action toolbar that allows resolving multiple conversations at once via POST /api/v1/bulk_actions. Sidebar width increased from w-80 to w-96 to accommodate the full action label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5a6598d commit 727b01f

9 files changed

Lines changed: 122 additions & 1 deletion

File tree

src/components/chat/chat-sidebar/ChatSidebar.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
22
import { Button } from '@evoapi/design-system/button';
3+
import { Checkbox } from '@evoapi/design-system/checkbox';
34
import { Input } from '@evoapi/design-system/input';
45
import { Badge } from '@evoapi/design-system/badge';
56
import {
@@ -93,6 +94,11 @@ interface ChatSidebarProps {
9394
onAssignTeam: (conversation: Conversation) => void;
9495
onAssignTag: (conversation: Conversation) => void;
9596
onDeleteConversation: (conversation: Conversation) => void;
97+
selectedConversationIds: Set<string>;
98+
onToggleSelect: (displayId: string) => void;
99+
onClearSelection: () => void;
100+
onBulkResolve: () => Promise<void>;
101+
isBulkResolving?: boolean;
96102
}
97103

98104
const ChatSidebar = ({
@@ -117,6 +123,11 @@ const ChatSidebar = ({
117123
onAssignTeam,
118124
onAssignTag,
119125
onDeleteConversation,
126+
selectedConversationIds,
127+
onToggleSelect,
128+
onClearSelection,
129+
onBulkResolve,
130+
isBulkResolving = false,
120131
}: ChatSidebarProps) => {
121132
const { t } = useLanguage('chat');
122133
const chatContext = useChatContext();
@@ -145,6 +156,11 @@ const ChatSidebar = ({
145156
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
146157
const loadingMoreRef = useRef(false);
147158

159+
useEffect(() => {
160+
onClearSelection();
161+
// eslint-disable-next-line react-hooks/exhaustive-deps
162+
}, [showArchived]);
163+
148164
// Pipeline state
149165
const [allPipelines, setAllPipelines] = useState<Pipeline[]>([]);
150166
const [isPipelinesLoaded, setIsPipelinesLoaded] = useState(false);
@@ -885,7 +901,7 @@ const ChatSidebar = ({
885901
data-tour="chat-sidebar"
886902
className={`
887903
${mobileView === 'list' ? 'flex' : 'hidden'} md:flex
888-
w-full md:w-80 border-r bg-card/50 flex-col h-full
904+
w-full md:w-96 border-r bg-card/50 flex-col h-full
889905
`}
890906
>
891907
{/* Search and Filter Header */}
@@ -973,6 +989,37 @@ const ChatSidebar = ({
973989
)}
974990
</div>
975991

992+
{/* Bulk Action Toolbar */}
993+
{selectedConversationIds.size > 0 && (
994+
<div className="px-3 py-2 border-b bg-primary/5 flex flex-col gap-1.5 flex-shrink-0">
995+
<div className="flex items-center justify-between">
996+
<span className="text-sm font-medium text-muted-foreground">
997+
{selectedConversationIds.size}{' '}
998+
{selectedConversationIds.size === 1
999+
? t('chatSidebar.conversation')
1000+
: t('chatSidebar.conversations')}
1001+
</span>
1002+
<Button
1003+
variant="ghost"
1004+
size="sm"
1005+
className="h-7 w-7 p-0 cursor-pointer"
1006+
onClick={onClearSelection}
1007+
>
1008+
<X className="h-3.5 w-3.5" />
1009+
</Button>
1010+
</div>
1011+
<Button
1012+
size="sm"
1013+
className="h-7 w-full cursor-pointer"
1014+
onClick={onBulkResolve}
1015+
disabled={isBulkResolving}
1016+
>
1017+
<CheckCircle className="h-3.5 w-3.5 mr-1.5" />
1018+
{t('chatHeader.actions.markAsResolved')}
1019+
</Button>
1020+
</div>
1021+
)}
1022+
9761023
{/* Conversations List */}
9771024
<div
9781025
ref={sidebarScrollRef}
@@ -1044,6 +1091,16 @@ const ChatSidebar = ({
10441091
>
10451092
<div className="flex items-start justify-between">
10461093
<div className="flex items-start gap-3 min-w-0 flex-1">
1094+
<div
1095+
className="mt-1 flex-shrink-0"
1096+
onClick={e => e.stopPropagation()}
1097+
>
1098+
<Checkbox
1099+
checked={selectedConversationIds.has(String(conversation.display_id))}
1100+
onCheckedChange={() => onToggleSelect(String(conversation.display_id))}
1101+
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"
1102+
/>
1103+
</div>
10471104
<ContactAvatar
10481105
contact={conversation.contact}
10491106
channelType={channelType}

src/i18n/locales/en/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@
302302
"searchPlaceholder": "Search conversations...",
303303
"conversation": "conversation",
304304
"conversations": "conversations",
305+
"selected": "selected",
305306
"filter": "filter",
306307
"filters": "filters",
307308
"filtersButton": "Filters",

src/i18n/locales/es/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"searchPlaceholder": "Buscar conversaciones...",
298298
"conversation": "conversación",
299299
"conversations": "conversaciones",
300+
"selected": "seleccionada(s)",
300301
"filter": "filtro",
301302
"filters": "filtros",
302303
"filtersButton": "Filtros",

src/i18n/locales/fr/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"searchPlaceholder": "Rechercher des conversations...",
298298
"conversation": "conversation",
299299
"conversations": "conversations",
300+
"selected": "sélectionnée(s)",
300301
"filter": "filtre",
301302
"filters": "filtres",
302303
"filtersButton": "Filtres",

src/i18n/locales/it/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"searchPlaceholder": "Cerca conversazioni...",
298298
"conversation": "conversazione",
299299
"conversations": "conversazioni",
300+
"selected": "selezionata/e",
300301
"filter": "filtro",
301302
"filters": "filtri",
302303
"filtersButton": "Filtri",

src/i18n/locales/pt-BR/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@
302302
"searchPlaceholder": "Buscar conversas...",
303303
"conversation": "conversa",
304304
"conversations": "conversas",
305+
"selected": "selecionada(s)",
305306
"filter": "filtro",
306307
"filters": "filtros",
307308
"filtersButton": "Filtros",

src/i18n/locales/pt/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"searchPlaceholder": "Buscar conversas...",
298298
"conversation": "conversa",
299299
"conversations": "conversas",
300+
"selected": "selecionada(s)",
300301
"filter": "filtro",
301302
"filters": "filtros",
302303
"filtersButton": "Filtros",

src/pages/Customer/Chat/Chat.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type { AssignmentOption, AssignmentType } from '@/components/chat/assignm
4646
import { labelsService } from '@/services/contacts/labelsService';
4747
import { useAppDataStore } from '@/store/appDataStore';
4848
import type { Label } from '@/types/settings';
49+
import { conversationAPI } from '@/services/conversations/conversationService';
4950

5051
const ContactSidebar = React.lazy(() => import('@/components/chat/contact-sidebar/ContactSidebar'));
5152

@@ -103,6 +104,10 @@ const Chat = () => {
103104
const [assignmentType, setAssignmentType] = useState<AssignmentType>('agent');
104105
const [conversationToAssign, setConversationToAssign] = useState<Conversation | null>(null);
105106

107+
// Bulk selection state
108+
const [selectedConversationIds, setSelectedConversationIds] = useState<Set<string>>(new Set());
109+
const [isBulkResolving, setIsBulkResolving] = useState(false);
110+
106111
// Dashboard Apps state (lazy loaded, not auto-fetched)
107112
const [dashboardApps] = useState<DashboardApp[]>([]);
108113
const [activeTab, setActiveTab] = useState<string>('chat');
@@ -132,6 +137,7 @@ const Chat = () => {
132137
// 🎯 FILTROS: Usar handlers dos hooks customizados (DEFINIR ANTES DOS useEffect)
133138
const handleApplyFilters = useCallback(
134139
async (newFilters: BaseFilter[]) => {
140+
setSelectedConversationIds(new Set());
135141
try {
136142
await filterHandlers.handleApplyFilters(newFilters);
137143
} catch (error) {
@@ -180,13 +186,51 @@ const Chat = () => {
180186
}, [permissionsReady]);
181187

182188
const handleClearFilters = useCallback(async () => {
189+
setSelectedConversationIds(new Set());
183190
await filterHandlers.handleClearFilters();
184191
}, [filterHandlers]);
185192

186193
const reloadCurrentFilters = useCallback(async () => {
187194
await filterHandlers.reloadCurrentFilters();
188195
}, [filterHandlers]);
189196

197+
const handleToggleConversationSelection = useCallback((displayId: string) => {
198+
setSelectedConversationIds(prev => {
199+
const next = new Set(prev);
200+
if (next.has(displayId)) {
201+
next.delete(displayId);
202+
} else {
203+
next.add(displayId);
204+
}
205+
return next;
206+
});
207+
}, []);
208+
209+
const handleClearSelection = useCallback(() => {
210+
setSelectedConversationIds(new Set());
211+
}, []);
212+
213+
const handleBulkResolve = useCallback(async () => {
214+
if (selectedConversationIds.size === 0) return;
215+
const displayIds = Array.from(selectedConversationIds);
216+
const resolvedConversations = conversations.state.conversations
217+
.filter(c => selectedConversationIds.has(String(c.display_id)));
218+
setIsBulkResolving(true);
219+
try {
220+
await conversationAPI.bulkResolve(displayIds);
221+
toast.success(`${displayIds.length} conversa(s) resolvida(s)`);
222+
setSelectedConversationIds(new Set());
223+
for (const conv of resolvedConversations) {
224+
conversations.updateConversation({ ...conv, status: 'resolved' });
225+
}
226+
} catch (error) {
227+
console.error('Bulk resolve error:', error);
228+
toast.error('Erro ao resolver conversas em lote');
229+
} finally {
230+
setIsBulkResolving(false);
231+
}
232+
}, [selectedConversationIds, conversations]);
233+
190234
// 🔄 CARREGAMENTO SIMPLES: Apenas carregar mensagens quando conversa muda
191235
useEffect(() => {
192236
if (conversations.state.selectedConversationId) {
@@ -714,6 +758,11 @@ const Chat = () => {
714758
onAssignTeam={handleAssignTeam}
715759
onAssignTag={handleAssignTag}
716760
onDeleteConversation={handleDeleteConversation}
761+
selectedConversationIds={selectedConversationIds}
762+
onToggleSelect={handleToggleConversationSelection}
763+
onClearSelection={handleClearSelection}
764+
onBulkResolve={handleBulkResolve}
765+
isBulkResolving={isBulkResolving}
717766
/>
718767

719768
{/* Chat Area */}

src/services/conversations/conversationService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ export const conversationAPI = {
181181
return extractData<any>(response);
182182
},
183183

184+
// Bulk resolve conversations by display_id
185+
async bulkResolve(displayIds: string[]): Promise<void> {
186+
await api.post('/bulk_actions', {
187+
type: 'Conversation',
188+
ids: displayIds,
189+
fields: { status: 'resolved' },
190+
});
191+
},
192+
184193
// Get conversation counts
185194
async getConversationCounts(): Promise<{
186195
open_count: number;

0 commit comments

Comments
 (0)