Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/components/chat/chat-sidebar/ChatSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -93,6 +94,12 @@ interface ChatSidebarProps {
onAssignTeam: (conversation: Conversation) => void;
onAssignTag: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
selectedConversationIds: Set<string>;
onToggleSelect: (displayId: string) => void;
onClearSelection: () => void;
onBulkResolve: () => Promise<void>;
isBulkResolving?: boolean;
canBulkResolve?: boolean;
}

const ChatSidebar = ({
Expand All @@ -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();
Expand Down Expand Up @@ -145,6 +158,10 @@ const ChatSidebar = ({
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
const loadingMoreRef = useRef(false);

useEffect(() => {
onClearSelection();
}, [showArchived, onClearSelection]);

// Pipeline state
const [allPipelines, setAllPipelines] = useState<Pipeline[]>([]);
const [isPipelinesLoaded, setIsPipelinesLoaded] = useState(false);
Expand Down Expand Up @@ -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 */}
Expand Down Expand Up @@ -973,6 +990,34 @@ const ChatSidebar = ({
)}
</div>

{/* Bulk Action Toolbar */}
{selectedConversationIds.size > 0 && (
<div className="px-3 py-2 border-b bg-primary/5 flex flex-col gap-1.5 flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{t('chatSidebar.selectedCount', { count: selectedConversationIds.size })}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={onClearSelection}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<Button
Comment on lines +1000 to +1009
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider providing a visual loading state for the bulk resolve button when isBulkResolving is true.

Disabling the button prevents duplicate submissions but doesn’t indicate progress to the user. Consider showing a loading state (e.g., spinner, updated label, or loading variant) while isBulkResolving is true to make the action feel responsive, especially for longer operations.

Suggested implementation:

          <Button
            size="sm"
            className="h-7 w-full cursor-pointer"
            onClick={onBulkResolve}
            disabled={isBulkResolving}
            aria-busy={isBulkResolving}
          >
            {isBulkResolving ? (
              <>
                <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
                {t('chatHeader.actions.markAsResolved')}
              </>
            ) : (
              <>
                <CheckCircle className="h-3.5 w-3.5 mr-1.5" />
                {t('chatHeader.actions.markAsResolved')}
              </>
            )}
          </Button>

You’ll also need to import the Loader2 icon at the top of the file alongside the other icons, for example:

  • If you currently have:
    import { X, CheckCircle } from 'lucide-react'
    change it to:
    import { X, CheckCircle, Loader2 } from 'lucide-react'

Adjust the exact import line to match how icons are imported elsewhere in this file.

size="sm"
className="h-7 w-full cursor-pointer"
onClick={onBulkResolve}
disabled={isBulkResolving || !canBulkResolve}
>
<CheckCircle className="h-3.5 w-3.5 mr-1.5" />
{t('chatHeader.actions.markAsResolved')}
</Button>
</div>
)}

{/* Conversations List */}
<div
ref={sidebarScrollRef}
Expand Down Expand Up @@ -1044,6 +1089,22 @@ const ChatSidebar = ({
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div
className="mt-1 flex-shrink-0"
onClick={e => e.stopPropagation()}
>
<Checkbox
checked={selectedConversationIds.has(String(conversation.display_id))}
onCheckedChange={(checked: boolean | 'indeterminate') => {
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"
/>
</div>
<ContactAvatar
contact={conversation.contact}
channelType={channelType}
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@
"assignAgent": "Assign Team Member",
"assignTeam": "Assign Team",
"assignTag": "Assign Label",
"deleteConversation": "Delete conversation"
"deleteConversation": "Delete conversation",
"bulkResolveSuccess": "{{count}} conversation(s) resolved",
"bulkResolvePartialSuccess": "{{success}} resolved, {{failed}} failed",
"bulkResolveError": "Error resolving conversations",
"bulkResolveNoPermission": "You do not have permission to resolve conversations"
},
"contactNoName": "Contact without name",
"status": "Status:",
Expand All @@ -302,6 +306,9 @@
"searchPlaceholder": "Search conversations...",
"conversation": "conversation",
"conversations": "conversations",
"selectedCount_one": "{{count}} conversation selected",
"selectedCount_other": "{{count}} conversations selected",
"selectConversation": "Select conversation",
"filter": "filter",
"filters": "filters",
"filtersButton": "Filters",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/es/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@
"assignAgent": "Asignar Agente",
"assignTeam": "Asignar Equipo",
"assignTag": "Asignar Etiqueta",
"deleteConversation": "Eliminar conversación"
"deleteConversation": "Eliminar conversación",
"bulkResolveSuccess": "{{count}} conversación(es) resuelta(s)",
"bulkResolvePartialSuccess": "{{success}} resuelta(s), {{failed}} fallida(s)",
"bulkResolveError": "Error al resolver conversaciones",
"bulkResolveNoPermission": "No tienes permiso para resolver conversaciones"
},
"contactNoName": "Contacto sin nombre",
"status": "Estado:",
Expand All @@ -297,6 +301,9 @@
"searchPlaceholder": "Buscar conversaciones...",
"conversation": "conversación",
"conversations": "conversaciones",
"selectedCount_one": "{{count}} conversación seleccionada",
"selectedCount_other": "{{count}} conversaciones seleccionadas",
"selectConversation": "Seleccionar conversación",
"filter": "filtro",
"filters": "filtros",
"filtersButton": "Filtros",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/fr/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@
"assignAgent": "Assigner un Agent",
"assignTeam": "Assigner une Équipe",
"assignTag": "Assigner une Étiquette",
"deleteConversation": "Supprimer la conversation"
"deleteConversation": "Supprimer la conversation",
"bulkResolveSuccess": "{{count}} conversation(s) résolue(s)",
"bulkResolvePartialSuccess": "{{success}} résolue(s), {{failed}} échouée(s)",
"bulkResolveError": "Erreur lors de la résolution des conversations",
"bulkResolveNoPermission": "Vous n'avez pas la permission de résoudre des conversations"
},
"contactNoName": "Contact sans nom",
"status": "Statut :",
Expand All @@ -297,6 +301,9 @@
"searchPlaceholder": "Rechercher des conversations...",
"conversation": "conversation",
"conversations": "conversations",
"selectedCount_one": "{{count}} conversation sélectionnée",
"selectedCount_other": "{{count}} conversations sélectionnées",
"selectConversation": "Sélectionner la conversation",
"filter": "filtre",
"filters": "filtres",
"filtersButton": "Filtres",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/it/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@
"assignAgent": "Assegna Agente",
"assignTeam": "Assegna Team",
"assignTag": "Assegna Etichetta",
"deleteConversation": "Elimina conversazione"
"deleteConversation": "Elimina conversazione",
"bulkResolveSuccess": "{{count}} conversazione/i risolta/e",
"bulkResolvePartialSuccess": "{{success}} risolta/e, {{failed}} fallita/e",
"bulkResolveError": "Errore nella risoluzione delle conversazioni",
"bulkResolveNoPermission": "Non hai il permesso di risolvere le conversazioni"
},
"contactNoName": "Contatto senza nome",
"status": "Stato:",
Expand All @@ -297,6 +301,9 @@
"searchPlaceholder": "Cerca conversazioni...",
"conversation": "conversazione",
"conversations": "conversazioni",
"selectedCount_one": "{{count}} conversazione selezionata",
"selectedCount_other": "{{count}} conversazioni selezionate",
"selectConversation": "Seleziona conversazione",
"filter": "filtro",
"filters": "filtri",
"filtersButton": "Filtri",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/pt-BR/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@
"assignAgent": "Atribuir Atendente",
"assignTeam": "Atribuir time",
"assignTag": "Atribuir etiqueta",
"deleteConversation": "Deletar conversa"
"deleteConversation": "Deletar conversa",
"bulkResolveSuccess": "{{count}} conversa(s) resolvida(s)",
"bulkResolvePartialSuccess": "{{success}} resolvida(s), {{failed}} falhada(s)",
"bulkResolveError": "Erro ao resolver conversas em lote",
"bulkResolveNoPermission": "Você não tem permissão para resolver conversas"
},
"contactNoName": "Contato sem nome",
"status": "Status:",
Expand All @@ -302,6 +306,9 @@
"searchPlaceholder": "Buscar conversas...",
"conversation": "conversa",
"conversations": "conversas",
"selectedCount_one": "{{count}} conversa selecionada",
"selectedCount_other": "{{count}} conversas selecionadas",
"selectConversation": "Selecionar conversa",
"filter": "filtro",
"filters": "filtros",
"filtersButton": "Filtros",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/pt/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@
"assignAgent": "Atribuir Atendente",
"assignTeam": "Atribuir time",
"assignTag": "Atribuir etiqueta",
"deleteConversation": "Deletar conversa"
"deleteConversation": "Deletar conversa",
"bulkResolveSuccess": "{{count}} conversa(s) resolvida(s)",
"bulkResolvePartialSuccess": "{{success}} resolvida(s), {{failed}} falhada(s)",
"bulkResolveError": "Erro ao resolver conversas em lote",
"bulkResolveNoPermission": "Não tem permissão para resolver conversas"
},
"contactNoName": "Contato sem nome",
"status": "Status:",
Expand All @@ -297,6 +301,9 @@
"searchPlaceholder": "Buscar conversas...",
"conversation": "conversa",
"conversations": "conversas",
"selectedCount_one": "{{count}} conversa selecionada",
"selectedCount_other": "{{count}} conversas selecionadas",
"selectConversation": "Selecionar conversa",
"filter": "filtro",
"filters": "filtros",
"filtersButton": "Filtros",
Expand Down
63 changes: 63 additions & 0 deletions src/pages/Customer/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type { AssignmentOption, AssignmentType } from '@/components/chat/assignm
import { labelsService } from '@/services/contacts/labelsService';
import { useAppDataStore } from '@/store/appDataStore';
import type { Label } from '@/types/settings';
import chatService from '@/services/chat/chatService';

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

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

// Bulk selection state
const [selectedConversationIds, setSelectedConversationIds] = useState<Set<string>>(new Set());
const [isBulkResolving, setIsBulkResolving] = useState(false);

// Dashboard Apps state (lazy loaded, not auto-fetched)
const [dashboardApps] = useState<DashboardApp[]>([]);
const [activeTab, setActiveTab] = useState<string>('chat');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -180,13 +186,62 @@ const Chat = () => {
}, [permissionsReady]);

const handleClearFilters = useCallback(async () => {
setSelectedConversationIds(new Set());
await filterHandlers.handleClearFilters();
}, [filterHandlers]);

const reloadCurrentFilters = useCallback(async () => {
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) {
Expand Down Expand Up @@ -655,6 +710,8 @@ const Chat = () => {
return;
}

setSelectedConversationIds(new Set());

// 🔒 MARCAR NAVEGAÇÃO MANUAL para evitar URL sync
isManualNavigationRef.current = true;

Expand Down Expand Up @@ -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 */}
Expand Down
Loading